From 3dfea50c536c708c78122b85eb32b24d7fda6e3b Mon Sep 17 00:00:00 2001
From: Knowen <47173160+Knowen@users.noreply.github.com>
Date: Mon, 8 Dec 2025 23:31:57 +0100
Subject: [PATCH 1/2] Add files via upload
Signed-off-by: Knowen <47173160+Knowen@users.noreply.github.com>
---
.../scripts/trackedCropTypes.lua | 84 ++++++++++---------
1 file changed, 43 insertions(+), 41 deletions(-)
diff --git a/FS25_YieldTracker/scripts/trackedCropTypes.lua b/FS25_YieldTracker/scripts/trackedCropTypes.lua
index 86bc399..ada02db 100644
--- a/FS25_YieldTracker/scripts/trackedCropTypes.lua
+++ b/FS25_YieldTracker/scripts/trackedCropTypes.lua
@@ -1,42 +1,44 @@
--- Author: BitBarn Mods
--- Date: 06-11-2025
--- Version: 1.0.0.0
--- trackedCropTypes.lua
-
-TrackedCropTypes = {
- { title = "Barley", fillTypeIndex = 3, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_barley.png"},
- { title = "Canola", fillTypeIndex = 5, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_canola.png"},
- { title = "Chaff", fillTypeIndex = 118, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_chaff.png"},
- { title = "Corn", fillTypeIndex = 9, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_maize.png"},
- { title = "Cotton", fillTypeIndex = 15, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_cotton.png"},
- { title = "Grass", fillTypeIndex = 28, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_grass.png"},
- { title = "Green Beans", fillTypeIndex = 18, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_greenbean.png"},
- { title = "Hay", fillTypeIndex = 30, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_dryGrass_windrow.png"},
- { title = "Long Grain Rice", fillTypeIndex = 16, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_riceLongGrain.png"},
- { title = "Oat", fillTypeIndex = 4, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_oat.png"},
- { title = "Peas", fillTypeIndex = 19, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_pea.png"},
- { title = "Potatoes", fillTypeIndex = 10, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_potato.png"},
- { title = "Rice", fillTypeIndex = 17, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_rice.png"},
- { title = "Sorghum", fillTypeIndex = 6, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_sorghum.png"},
- { title = "Soybeans", fillTypeIndex = 8, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_soyBean.png"},
- { title = "Spinach", fillTypeIndex = 20, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_spinach.png"},
- { title = "Straw", fillTypeIndex = 31, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_straw.png"},
- { title = "Sunflowers", fillTypeIndex = 7, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_sunflower.png"},
- { title = "Wheat", fillTypeIndex = 2, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_wheat.png"},
-}
-
--- TODO: NEEDS SEPERATE FILL TRACKING METHOD TO ACCURATELY TRACK THESES FILL TYPES (expected in v1.0.1.0)
--- { title = "Carrots", fillTypeIndex = 13, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_carrot.png" },
--- { title = "Red Beet", fillTypeIndex = 12, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_beetRoot.png" },
--- { title = "Parsnips", fillTypeIndex = 14, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_parsnip.png" },
--- { title = "Sugar Beet", fillTypeIndex = 11, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_sugarBeet.png" },
--- { title = "Sugarcane", fillTypeIndex = 21, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_sugarCane.png" },
-
-
--- BUG: GRAPE/OLIVE HARVESTERS NOT BEING TRACKED
--- { title = "Olives", fillTypeIndex = 38, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_olive.png" },
--- { title = "Grapes", fillTypeIndex = 37, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_grape.png" },
-
-
--- DEPRACTED: NOT A HARVESTABLE CROP, USED AS FERT
+-- Author: BitBarn Mods
+-- Date: 06-11-2025
+-- Version: 1.0.0.1
+-- trackedCropTypes.lua
+
+TrackedCropTypes = {
+ { title = "Barley", fillTypeIndex = 3, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_barley.png"},
+ { title = "Canola", fillTypeIndex = 5, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_canola.png"},
+ { title = "Chaff", fillTypeIndex = 118, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_chaff.png"},
+ { title = "Corn", fillTypeIndex = 9, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_maize.png"},
+ { title = "Cotton", fillTypeIndex = 15, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_cotton.png"},
+ { title = "Grass", fillTypeIndex = 28, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_grass.png"},
+ { title = "Green Beans", fillTypeIndex = 18, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_greenbean.png"},
+ { title = "Hay", fillTypeIndex = 30, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_dryGrass_windrow.png"},
+ { title = "Long Grain Rice", fillTypeIndex = 16, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_riceLongGrain.png"},
+ { title = "Oat", fillTypeIndex = 4, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_oat.png"},
+ { title = "Peas", fillTypeIndex = 19, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_pea.png"},
+ { title = "Potatoes", fillTypeIndex = 10, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_potato.png"},
+ { title = "Rice", fillTypeIndex = 17, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_rice.png"},
+ { title = "Sorghum", fillTypeIndex = 6, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_sorghum.png"},
+ { title = "Soybeans", fillTypeIndex = 8, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_soyBean.png"},
+ { title = "Spinach", fillTypeIndex = 20, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_spinach.png"},
+ { title = "Straw", fillTypeIndex = 31, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_straw.png"},
+ { title = "Sunflowers", fillTypeIndex = 7, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_sunflower.png"},
+ { title = "Wheat", fillTypeIndex = 2, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_wheat.png"},
+ { title = "Spelt", fillTypeIndex = 183, hudOverlayFilename = "$moddir$/NFMarsch/config/huds/hud_fill_spelt.dds"},
+ { title = "Rye", fillTypeIndex = 184, hudOverlayFilename = "$moddir$/NFMarsch/config/huds/hud_fill_rye.dds"},
+}
+
+-- TODO: NEEDS SEPERATE FILL TRACKING METHOD TO ACCURATELY TRACK THESES FILL TYPES (expected in v1.0.1.0)
+-- { title = "Carrots", fillTypeIndex = 13, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_carrot.png" },
+-- { title = "Red Beet", fillTypeIndex = 12, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_beetRoot.png" },
+-- { title = "Parsnips", fillTypeIndex = 14, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_parsnip.png" },
+-- { title = "Sugar Beet", fillTypeIndex = 11, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_sugarBeet.png" },
+-- { title = "Sugarcane", fillTypeIndex = 21, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_sugarCane.png" },
+
+
+-- BUG: GRAPE/OLIVE HARVESTERS NOT BEING TRACKED
+-- { title = "Olives", fillTypeIndex = 38, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_olive.png" },
+-- { title = "Grapes", fillTypeIndex = 37, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_grape.png" },
+
+
+-- DEPRACTED: NOT A HARVESTABLE CROP, USED AS FERT
-- { title = "Oilseed Radish", fillTypeIndex = 121, hudOverlayFilename = "dataS/menu/hud/fillTypes/hud_fill_oilRadish.png" },
\ No newline at end of file
From 966c40c028bc3aa7198846f58608da789b1fe8a0 Mon Sep 17 00:00:00 2001
From: Knowen <47173160+Knowen@users.noreply.github.com>
Date: Thu, 16 Jul 2026 21:20:25 +0200
Subject: [PATCH 2/2] Add files via upload
changed how this mod detects all fruits which are registered in the map. this also includes custom crops. also detects the correct hud image.
also changed how the yield is calculated. it now tracks the amount of crop which goes through the pipe of a combine or forage harvester. grass and hay is still the same with bales.
tried also to adjust the GUI so it is always centered. all the GUI elements are far to the left and are not readable if you play on an ultrawide like I do.
Signed-off-by: Knowen <47173160+Knowen@users.noreply.github.com>
---
FS25_YieldTracker/gui/guiProfiles.xml | 298 +--
FS25_YieldTracker/gui/yieldTrackerPage.xml | 184 +-
FS25_YieldTracker/main.lua | 86 +-
FS25_YieldTracker/modDesc.xml | 288 +--
.../scripts/gui/yieldTrackerGUI.lua | 216 +--
.../scripts/gui/yieldTrackerPage.lua | 337 +++-
.../scripts/gui/yieldTrackerPage_backup.lua | 723 ++++++++
FS25_YieldTracker/scripts/yieldTracker.lua | 1594 +++++++++++++++--
.../scripts/yieldTracker_backup.lua | 1414 +++++++++++++++
.../translations/translation_br.xml | 58 +-
.../translations/translation_cs.xml | 58 +-
.../translations/translation_de.xml | 58 +-
.../translations/translation_en.xml | 56 +-
.../translations/translation_fr.xml | 58 +-
.../translations/translation_pl.xml | 58 +-
.../translations/translation_pt.xml | 58 +-
16 files changed, 4550 insertions(+), 994 deletions(-)
create mode 100644 FS25_YieldTracker/scripts/gui/yieldTrackerPage_backup.lua
create mode 100644 FS25_YieldTracker/scripts/yieldTracker_backup.lua
diff --git a/FS25_YieldTracker/gui/guiProfiles.xml b/FS25_YieldTracker/gui/guiProfiles.xml
index 50de208..5da488b 100644
--- a/FS25_YieldTracker/gui/guiProfiles.xml
+++ b/FS25_YieldTracker/gui/guiProfiles.xml
@@ -1,147 +1,151 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FS25_YieldTracker/gui/yieldTrackerPage.xml b/FS25_YieldTracker/gui/yieldTrackerPage.xml
index e9315d1..0e32f35 100644
--- a/FS25_YieldTracker/gui/yieldTrackerPage.xml
+++ b/FS25_YieldTracker/gui/yieldTrackerPage.xml
@@ -1,6 +1,6 @@
-
+
@@ -11,123 +11,101 @@
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
-
+
+
+
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
\ No newline at end of file
+
diff --git a/FS25_YieldTracker/main.lua b/FS25_YieldTracker/main.lua
index 23ecc05..6d3b59a 100644
--- a/FS25_YieldTracker/main.lua
+++ b/FS25_YieldTracker/main.lua
@@ -1,44 +1,44 @@
--- Author: BitBarn Mods
--- Date: 06-11-2025
--- Version: 1.0.0.0
--- main.lua
-
----Main handler table for YieldTracker mod.
----Controls mod initialization, update cycle, and cleanup.
-MainHandler = {
- dir = g_currentModDirectory, -- Directory where the mod is located
- enabled = true -- Whether the mod is active
-}
-
--- Load core mod logic and GUI scripts
-source(MainHandler.dir .. "scripts/yieldTracker.lua")
-source(MainHandler.dir .. "scripts/gui/yieldTrackerGUI.lua")
-
----Called once when the map is loaded.
----Disables the mod in multiplayer mode; otherwise, initializes tracking systems.
-function MainHandler:loadMap(name)
- if g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
- print("[YieldTracker] Multiplayer detected. Disabling mod.")
- self.enabled = false
- g_currentMission:showBlinkingWarning("YieldTracker disabled in multiplayer mode.", 5000)
- return
- end
-
- YieldTracker:setup() -- Initialize yield tracking logic
- YieldTrackerGUI:setup() -- Initialize the GUI components
-end
-
----Called every frame to perform per-tick updates.
----Only runs if mod is enabled. Delegates update logic to YieldTracker.
-function MainHandler:update(dt)
- if not self.enabled then return end
- YieldTracker:updateByFrame()
-end
-
----Called when the map is being deleted/unloaded.
----Stub provided for potential future cleanup logic.
-function MainHandler:deleteMap()
-end
-
--- Register MainHandler to receive game loop events (loadMap, update, etc.)
+-- Author: BitBarn Mods
+-- Date: 06-11-2025
+-- Version: 1.0.0.0
+-- main.lua
+
+---Main handler table for YieldTracker mod.
+---Controls mod initialization, update cycle, and cleanup.
+MainHandler = {
+ dir = g_currentModDirectory, -- Directory where the mod is located
+ enabled = true -- Whether the mod is active
+}
+
+-- Load core mod logic and GUI scripts
+source(MainHandler.dir .. "scripts/yieldTracker.lua")
+source(MainHandler.dir .. "scripts/gui/yieldTrackerGUI.lua")
+
+---Called once when the map is loaded.
+---Disables the mod in multiplayer mode; otherwise, initializes tracking systems.
+function MainHandler:loadMap(name)
+ if g_currentMission and g_currentMission.missionDynamicInfo and g_currentMission.missionDynamicInfo.isMultiplayer then
+ print("[YieldTracker] Multiplayer detected. Disabling mod.")
+ self.enabled = false
+ g_currentMission:showBlinkingWarning("YieldTracker disabled in multiplayer mode.", 5000)
+ return
+ end
+
+ YieldTracker:setup() -- Initialize yield tracking logic
+ YieldTrackerGUI:setup() -- Initialize the GUI components
+end
+
+---Called every frame to perform per-tick updates.
+---Only runs if mod is enabled. Delegates update logic to YieldTracker.
+function MainHandler:update(dt)
+ if not self.enabled then return end
+ YieldTracker:updateByFrame()
+end
+
+---Called when the map is being deleted/unloaded.
+---Stub provided for potential future cleanup logic.
+function MainHandler:deleteMap()
+end
+
+-- Register MainHandler to receive game loop events (loadMap, update, etc.)
addModEventListener(MainHandler)
\ No newline at end of file
diff --git a/FS25_YieldTracker/modDesc.xml b/FS25_YieldTracker/modDesc.xml
index fa071ab..dca6a55 100644
--- a/FS25_YieldTracker/modDesc.xml
+++ b/FS25_YieldTracker/modDesc.xml
@@ -1,144 +1,144 @@
-
-
-BitBarn Mods
-1.0.0.0
-
-Yield Tracker
-Ertrags-Tracker
-Suivi Du Rendement
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-icon_YieldTracker.dds
-
-
-
-
-
-
+
+
+BitBarn Mods / Knowen
+1.0.1.4
+
+Yield Tracker
+Ertrags-Tracker
+Suivi Du Rendement
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+icon_YieldTracker.dds
+
+
+
+
+
+
diff --git a/FS25_YieldTracker/scripts/gui/yieldTrackerGUI.lua b/FS25_YieldTracker/scripts/gui/yieldTrackerGUI.lua
index 9dd675a..d7b0cf5 100644
--- a/FS25_YieldTracker/scripts/gui/yieldTrackerGUI.lua
+++ b/FS25_YieldTracker/scripts/gui/yieldTrackerGUI.lua
@@ -1,108 +1,108 @@
--- Author: BitBarn Mods
--- Date: 06-11-2025
--- Version: 1.0.0.0
--- yieldTrackerGUI.lua
-
----Handles GUI integration for the YieldTracker mod.
----Responsible for loading GUI pages, registering with InGameMenu, and managing page layout.
-YieldTrackerGUI = {
- dir = g_currentModDirectory, -- Mod directory path
- modName = g_currentModName -- Name of the current mod
-}
-
--- Load the YieldTrackerPage class for GUI logic
-source(YieldTrackerGUI.dir .. "scripts/gui/yieldTrackerPage.lua") -- > YieldTrackerPage
-
----Helper function: Finds the index of a specific value in a sequential table.
----@param tbl table The table to search
----@param value any The value to find
----@return integer? Index of the value or nil if not found
-local function findIndex(tbl, value)
- for i, v in ipairs(tbl) do
- if v == value then
- return i
- end
- end
- return nil
-end
-
----Helper function: Moves an element to a new position within a table.
----@param tbl table The table to modify
----@param element any The element to move
----@param position integer The new index position
-local function moveElement(tbl, element, position)
- for i, v in ipairs(tbl) do
- if v == element then
- table.remove(tbl, i)
- table.insert(tbl, position, element)
- break
- end
- end
-end
-
----Initializes the YieldTracker GUI when the map loads.
----Loads GUI profiles, creates the GUI page, and inserts it into the in-game menu.
-function YieldTrackerGUI:setup()
- local inGameMenu = g_gui.screenControllers[InGameMenu]
-
- -- Load custom GUI styles from XML
- g_gui:loadProfiles(self.dir .. "gui/guiProfiles.xml")
-
- -- Instantiate and load the YieldTracker GUI page
- local yieldPage = YieldTrackerPage.new(g_i18n, g_messageCenter)
- g_gui:loadGui(self.dir .. "gui/yieldTrackerPage.xml", "yieldTrackerPage", yieldPage, true)
-
- -- Add the new page to the in-game menu using the helper
- self.fixInGameMenu(yieldPage, "yieldTrackerPage", {0, 0, 1024, 1024}, 2, nil)
-end
-
----Registers a custom GUI page with the in-game menu.
----Ensures correct ordering, visual integration, and control registration.
----@param frame table The GUI frame to add
----@param pageName string Unique name for the page
----@param uvs table UV coordinates for the page tab icon
----@param position integer Index position for the new page
----@param predicateFunc function? Optional function to determine page visibility
-function YieldTrackerGUI.fixInGameMenu(frame, pageName, uvs, position, predicateFunc)
- local inGameMenu = g_gui.screenControllers[InGameMenu]
-
- -- Avoid duplicate control ID conflicts
- inGameMenu.controlIDs[pageName] = nil
-
- -- Find insertion point based on existing pages (e.g. above pageStatistics)
- local abovePrices = findIndex(inGameMenu.pagingElement.elements, inGameMenu.pageStatistics) or position
-
- -- Register the frame and expose it in the menu
- inGameMenu[pageName] = frame
- inGameMenu.pagingElement:addElement(frame)
- inGameMenu:exposeControlsAsFields(pageName)
-
- -- Move the new frame into the desired slot in the page list
- moveElement(inGameMenu.pagingElement.elements, frame, abovePrices)
-
- -- Adjust internal page references accordingly
- for i, page in ipairs(inGameMenu.pagingElement.pages) do
- if page.element == frame then
- table.remove(inGameMenu.pagingElement.pages, i)
- table.insert(inGameMenu.pagingElement.pages, abovePrices, page)
- break
- end
- end
-
- -- Refresh layout and page mappings after changes
- inGameMenu.pagingElement:updateAbsolutePosition()
- inGameMenu.pagingElement:updatePageMapping()
-
- -- Register logic to determine when the page is enabled
- inGameMenu:registerPage(frame, position, predicateFunc)
-
- -- Add tab icon to the menu bar
- local iconPath = Utils.getFilename("images/yieldTrackerMenuIcon.dds", YieldTrackerGUI.dir)
- inGameMenu:addPageTab(frame, iconPath, GuiUtils.getUVs(uvs))
-
- -- Ensure the new page appears in the correct order in pageFrames
- moveElement(inGameMenu.pageFrames, frame, abovePrices)
-
- -- Rebuild tab list to include the new page
- inGameMenu:rebuildTabList()
-end
+-- Author: BitBarn Mods
+-- Date: 06-11-2025
+-- Version: 1.0.0.0
+-- yieldTrackerGUI.lua
+
+---Handles GUI integration for the YieldTracker mod.
+---Responsible for loading GUI pages, registering with InGameMenu, and managing page layout.
+YieldTrackerGUI = {
+ dir = g_currentModDirectory, -- Mod directory path
+ modName = g_currentModName -- Name of the current mod
+}
+
+-- Load the YieldTrackerPage class for GUI logic
+source(YieldTrackerGUI.dir .. "scripts/gui/yieldTrackerPage.lua") -- > YieldTrackerPage
+
+---Helper function: Finds the index of a specific value in a sequential table.
+---@param tbl table The table to search
+---@param value any The value to find
+---@return integer? Index of the value or nil if not found
+local function findIndex(tbl, value)
+ for i, v in ipairs(tbl) do
+ if v == value then
+ return i
+ end
+ end
+ return nil
+end
+
+---Helper function: Moves an element to a new position within a table.
+---@param tbl table The table to modify
+---@param element any The element to move
+---@param position integer The new index position
+local function moveElement(tbl, element, position)
+ for i, v in ipairs(tbl) do
+ if v == element then
+ table.remove(tbl, i)
+ table.insert(tbl, position, element)
+ break
+ end
+ end
+end
+
+---Initializes the YieldTracker GUI when the map loads.
+---Loads GUI profiles, creates the GUI page, and inserts it into the in-game menu.
+function YieldTrackerGUI:setup()
+ local inGameMenu = g_gui.screenControllers[InGameMenu]
+
+ -- Load custom GUI styles from XML
+ g_gui:loadProfiles(self.dir .. "gui/guiProfiles.xml")
+
+ -- Instantiate and load the YieldTracker GUI page
+ local yieldPage = YieldTrackerPage.new(g_i18n, g_messageCenter)
+ g_gui:loadGui(self.dir .. "gui/yieldTrackerPage.xml", "yieldTrackerPage", yieldPage, true)
+
+ -- Add the new page to the in-game menu using the helper
+ self.fixInGameMenu(yieldPage, "yieldTrackerPage", {0, 0, 1024, 1024}, 2, nil)
+end
+
+---Registers a custom GUI page with the in-game menu.
+---Ensures correct ordering, visual integration, and control registration.
+---@param frame table The GUI frame to add
+---@param pageName string Unique name for the page
+---@param uvs table UV coordinates for the page tab icon
+---@param position integer Index position for the new page
+---@param predicateFunc function? Optional function to determine page visibility
+function YieldTrackerGUI.fixInGameMenu(frame, pageName, uvs, position, predicateFunc)
+ local inGameMenu = g_gui.screenControllers[InGameMenu]
+
+ -- Avoid duplicate control ID conflicts
+ inGameMenu.controlIDs[pageName] = nil
+
+ -- Find insertion point based on existing pages (e.g. above pageStatistics)
+ local abovePrices = findIndex(inGameMenu.pagingElement.elements, inGameMenu.pageStatistics) or position
+
+ -- Register the frame and expose it in the menu
+ inGameMenu[pageName] = frame
+ inGameMenu.pagingElement:addElement(frame)
+ inGameMenu:exposeControlsAsFields(pageName)
+
+ -- Move the new frame into the desired slot in the page list
+ moveElement(inGameMenu.pagingElement.elements, frame, abovePrices)
+
+ -- Adjust internal page references accordingly
+ for i, page in ipairs(inGameMenu.pagingElement.pages) do
+ if page.element == frame then
+ table.remove(inGameMenu.pagingElement.pages, i)
+ table.insert(inGameMenu.pagingElement.pages, abovePrices, page)
+ break
+ end
+ end
+
+ -- Refresh layout and page mappings after changes
+ inGameMenu.pagingElement:updateAbsolutePosition()
+ inGameMenu.pagingElement:updatePageMapping()
+
+ -- Register logic to determine when the page is enabled
+ inGameMenu:registerPage(frame, position, predicateFunc)
+
+ -- Add tab icon to the menu bar
+ local iconPath = Utils.getFilename("images/yieldTrackerMenuIcon.dds", YieldTrackerGUI.dir)
+ inGameMenu:addPageTab(frame, iconPath, GuiUtils.getUVs(uvs))
+
+ -- Ensure the new page appears in the correct order in pageFrames
+ moveElement(inGameMenu.pageFrames, frame, abovePrices)
+
+ -- Rebuild tab list to include the new page
+ inGameMenu:rebuildTabList()
+end
diff --git a/FS25_YieldTracker/scripts/gui/yieldTrackerPage.lua b/FS25_YieldTracker/scripts/gui/yieldTrackerPage.lua
index 899843b..648db7f 100644
--- a/FS25_YieldTracker/scripts/gui/yieldTrackerPage.lua
+++ b/FS25_YieldTracker/scripts/gui/yieldTrackerPage.lua
@@ -12,11 +12,6 @@ YieldTrackerPage.debug = false
YieldTrackerPage.dir = g_currentModDirectory
-- source(g_currentModDirectory .. "scripts/yieldTrackerPage.lua") --> YieldTracker.yieldData -- BUG -- CANNOT LOAD RESOURCE, THIS IS NOT THE CORRECT PATH FOR THE DATA. TESTING NEEDED TO SEE IF REQUIRED AND IF SO CHANGE PATH
-source(g_currentModDirectory .. "scripts/trackedCropTypes.lua") --> TrackedCropTypes
-
-local trackedCropTypes = TrackedCropTypes
-
-
function YieldTrackerPage.new(i18n, messageCenter)
local self = YieldTrackerPage:superClass().new(nil, YieldTrackerPage._mt)
self.name = "YieldTrackerPage"
@@ -26,8 +21,9 @@ function YieldTrackerPage.new(i18n, messageCenter)
self.dataBindings = {}
self.farmlandIDs = {}
self.yieldYears = {}
- self.cropInfo = trackedCropTypes
- self.selectedCrop = trackedCropTypes[1].fillTypeIndex
+ -- Dynamically filled from g_fruitTypeManager in onGuiSetupFinished/onFrameOpen
+ self.cropInfo = {}
+ self.selectedCrop = nil
self.selectedField = 1
self.showingOwnedFields = false
@@ -48,6 +44,202 @@ local function formatWithCommas(n)
end
+function YieldTrackerPage:getLocalizedTextOrFallback(key, fallback)
+ if key ~= nil and key ~= "" then
+ local text = self.i18n:getText(key)
+ if text ~= nil and text ~= key and text ~= "" then
+ return text
+ end
+ end
+
+ return fallback or ""
+end
+
+
+function YieldTrackerPage:getFruitTypeDisplayName(fruitType, fillType)
+ local rawTitle = nil
+
+ if fillType ~= nil then
+ rawTitle = fillType.title or fillType.name
+ end
+
+ rawTitle = rawTitle or fruitType.title or fruitType.name or ""
+
+ if type(rawTitle) == "string" and rawTitle:sub(1, 6) == "$l10n_" then
+ return self:getLocalizedTextOrFallback(rawTitle:sub(7), fruitType.name or rawTitle)
+ end
+
+ return rawTitle
+end
+
+
+function YieldTrackerPage:getFillTypeIndexByNameSafe(fillTypeName)
+ if fillTypeName == nil or g_fillTypeManager == nil then
+ return nil
+ end
+
+ if g_fillTypeManager.getFillTypeIndexByName ~= nil then
+ local ok, result = pcall(function() return g_fillTypeManager:getFillTypeIndexByName(fillTypeName) end)
+ if ok and result ~= nil and result ~= FillType.UNKNOWN then
+ return result
+ end
+ end
+
+ if g_fillTypeManager.nameToIndex ~= nil then
+ local result = g_fillTypeManager.nameToIndex[fillTypeName]
+ if result ~= nil and result ~= FillType.UNKNOWN then
+ return result
+ end
+ end
+
+ if g_fillTypeManager.nameToFillType ~= nil and g_fillTypeManager.nameToFillType[fillTypeName] ~= nil then
+ local desc = g_fillTypeManager.nameToFillType[fillTypeName]
+ local result = desc.index or desc.fillTypeIndex
+ if result ~= nil and result ~= FillType.UNKNOWN then
+ return result
+ end
+ end
+
+ if g_fillTypeManager.indexToFillType ~= nil then
+ for index, desc in pairs(g_fillTypeManager.indexToFillType) do
+ if desc ~= nil and desc.name == fillTypeName then
+ return index
+ end
+ end
+ end
+
+ return nil
+end
+
+function YieldTrackerPage:getFillTypeDescByIndexSafe(fillTypeIndex)
+ if fillTypeIndex == nil or g_fillTypeManager == nil then
+ return nil
+ end
+
+ if g_fillTypeManager.getFillTypeByIndex ~= nil then
+ local ok, result = pcall(function() return g_fillTypeManager:getFillTypeByIndex(fillTypeIndex) end)
+ if ok and result ~= nil then
+ return result
+ end
+ end
+
+ if g_fillTypeManager.indexToFillType ~= nil then
+ return g_fillTypeManager.indexToFillType[fillTypeIndex]
+ end
+
+ return nil
+end
+
+function YieldTrackerPage:getFillTypeDisplayName(fillTypeIndex, fallbackName)
+ local desc = self:getFillTypeDescByIndexSafe(fillTypeIndex)
+ local rawTitle = fallbackName
+
+ if desc ~= nil then
+ rawTitle = desc.title or desc.name or fallbackName
+ end
+
+ if type(rawTitle) == "string" and rawTitle:sub(1, 6) == "$l10n_" then
+ return self:getLocalizedTextOrFallback(rawTitle:sub(7), fallbackName or rawTitle)
+ end
+
+ return rawTitle or tostring(fillTypeIndex)
+end
+
+function YieldTrackerPage:addExtraFillTypeToCropList(crops, usedFillTypes, fillTypeName, fallbackTitle)
+ local fillTypeIndex = self:getFillTypeIndexByNameSafe(fillTypeName)
+ if fillTypeIndex == nil or usedFillTypes[fillTypeIndex] ~= nil then
+ return false
+ end
+
+ local desc = self:getFillTypeDescByIndexSafe(fillTypeIndex)
+ local icon = ""
+
+ if desc ~= nil then
+ icon = desc.hudOverlayFilename or desc.iconFilename or ""
+ end
+
+ local title = self:getFillTypeDisplayName(fillTypeIndex, fallbackTitle or fillTypeName)
+
+ table.insert(crops, {
+ fruitTypeIndex = nil,
+ fillTypeIndex = fillTypeIndex,
+ name = fillTypeName,
+ title = title,
+ hudOverlayFilename = icon,
+ isExtraFillType = true
+ })
+
+ usedFillTypes[fillTypeIndex] = true
+ return true
+end
+
+
+
+function YieldTrackerPage:refreshCropInfoFromFruitTypeManager()
+ local crops = {}
+ local usedFillTypes = {}
+
+ if g_fruitTypeManager == nil or g_fruitTypeManager.getFruitTypes == nil then
+ self:debugMessage("FruitTypeManager not available yet; crop list remains empty")
+ self.cropInfo = crops
+ self.selectedCrop = nil
+ return crops
+ end
+
+ for _, fruitType in ipairs(g_fruitTypeManager:getFruitTypes()) do
+ if fruitType ~= nil then
+ local fillType = fruitType.fillType
+ local fillTypeIndex = nil
+
+ if fillType ~= nil then
+ fillTypeIndex = fillType.index or fillType.fillTypeIndex
+ end
+
+ fillTypeIndex = fillTypeIndex or fruitType.fillTypeIndex
+
+ if fillTypeIndex ~= nil and usedFillTypes[fillTypeIndex] == nil then
+ usedFillTypes[fillTypeIndex] = true
+
+ local title = self:getFruitTypeDisplayName(fruitType, fillType)
+ local icon = nil
+
+ if fillType ~= nil then
+ icon = fillType.hudOverlayFilename or fillType.iconFilename
+ end
+
+ icon = icon or fruitType.hudOverlayFilename or fruitType.iconFilename or ""
+
+ table.insert(crops, {
+ fruitTypeIndex = fruitType.index,
+ fillTypeIndex = fillTypeIndex,
+ name = fruitType.name or title,
+ title = title,
+ hudOverlayFilename = icon
+ })
+ end
+ end
+ end
+
+ -- Extra fill types that are not always exposed as FruitTypes, but are relevant for yield tracking.
+ self:addExtraFillTypeToCropList(crops, usedFillTypes, "DRYGRASS_WINDROW", "Heu")
+ self:addExtraFillTypeToCropList(crops, usedFillTypes, "CHAFF", "Häckselgut")
+ self:addExtraFillTypeToCropList(crops, usedFillTypes, "STRAW", "Stroh")
+ self:addExtraFillTypeToCropList(crops, usedFillTypes, "STRAW_WINDROW", "Stroh")
+
+ table.sort(crops, function(a, b)
+ return tostring(a.title):lower() < tostring(b.title):lower()
+ end)
+
+ self.cropInfo = crops
+
+ if self.selectedCrop == nil or usedFillTypes[self.selectedCrop] == nil then
+ self.selectedCrop = crops[1] ~= nil and crops[1].fillTypeIndex or nil
+ end
+
+ return crops
+end
+
+
function YieldTrackerPage:setupCustomButtons()
self.backButtonInfo = {
@@ -119,22 +311,38 @@ function YieldTrackerPage:onGuiSetupFinished()
-- farm id list handling
self.farmlandList = self:getDescendantById("farmlandList")
- self.farmlandList:setDataSource(self)
- self.farmlandList:setDelegate(self)
+ if self.farmlandList ~= nil then
+ self.farmlandList:setDataSource(self)
+ self.farmlandList:setDelegate(self)
+ end
-- crop list/fill type handling
self.cropList = self:getDescendantById("cropList")
- self.cropList:setDataSource(self)
- self.cropList:setDelegate(self)
-
- if self.cropList then
+ self:refreshCropInfoFromFruitTypeManager()
+ if self.cropList ~= nil then
+ self.cropList:setDataSource(self)
+ self.cropList:setDelegate(self)
self.cropList:reloadData()
end
-- users yield history list
self.yieldPerYearList = self:getDescendantById("yieldPerYearList")
- self.yieldPerYearList:setDataSource(self)
- self.yieldPerYearList:setDelegate(self)
+ if self.yieldPerYearList ~= nil then
+ self.yieldPerYearList:setDataSource(self)
+ self.yieldPerYearList:setDelegate(self)
+ end
+ self.noYieldHistoryText = self:getDescendantById("noYieldHistoryText")
+ self.yieldStatsInformation = self:getDescendantById("yieldStatsInformation")
+ self.yieldListMode = self:getDescendantById("yieldListMode")
+ self.yieldDetailMode = self:getDescendantById("yieldDetailMode")
+
+ -- Additional GUI elements used by the redesigned XML layout
+ self.yieldListMode = self:getDescendantById("yieldListMode")
+ self.yieldDetailMode = self:getDescendantById("yieldDetailMode")
+ self.noYieldHistoryText = self:getDescendantById("noYieldHistoryText")
+ self.yieldStatsInformation = self:getDescendantById("yieldStatsInformation")
+ self.graphYearTickTemplate = self:getDescendantById("graphYearTickTemplate")
+ self.graphCandleTemplate = self:getDescendantById("graphCandleTemplate")
-- debuggers
self:debugMessage("YieldPerYearList found: " .. tostring(self.yieldPerYearList ~= nil))
@@ -150,6 +358,11 @@ end
function YieldTrackerPage:onFrameOpen()
YieldTrackerPage:superClass().onFrameOpen(self)
self:getFarmlandIDs()
+ self:refreshCropInfoFromFruitTypeManager()
+
+ if self.cropList then
+ self.cropList:reloadData()
+ end
if self.farmlandList then
self.farmlandList:reloadData()
@@ -187,18 +400,45 @@ end
function YieldTrackerPage:onClickCropSelected(element)
local selected = self.cropInfo[element.indexInSection]
+ if selected == nil then
+ return
+ end
+
self.selectedCrop = selected.fillTypeIndex
self:setSelectedPageData()
+
+ -- NEW: switch to detail view when both selected
+ if self.selectedField ~= nil and self.selectedCrop ~= nil then
+ if self.yieldListMode ~= nil then
+ self.yieldListMode:setVisible(false)
+ end
+ if self.yieldDetailMode ~= nil then
+ self.yieldDetailMode:setVisible(true)
+ end
+ end
end
function YieldTrackerPage:onClickFieldSelected(element)
self.selectedField = self.farmlandIDs[element.indexInSection]
self:setSelectedPageData()
+
+ -- NEW: stay in list until crop is selected
+ if self.yieldListMode ~= nil then
+ self.yieldListMode:setVisible(true)
+ end
+ if self.yieldDetailMode ~= nil then
+ self.yieldDetailMode:setVisible(false)
+ end
end
function YieldTrackerPage:setSelectedPageData()
+ if self.selectedCrop == nil then
+ self.yieldYears = {}
+ return
+ end
+
self:initiateFieldCropStatsSearch()
self:setOverviewStatsText()
@@ -214,6 +454,10 @@ function YieldTrackerPage:initiateFieldCropStatsSearch()
local foundData = false
self.yieldYears = {} -- Clear existing data
+ if selectedFillTypeIndex == nil or selectedField == nil then
+ return
+ end
+
self:debugMessage(string.format("Search stats for fill type index %d on field %d", selectedFillTypeIndex, selectedField))
local yearAmountMap = {}
@@ -272,8 +516,18 @@ function YieldTrackerPage:initiateFieldCropStatsSearch()
end
-- Show message only if no data found
- self.noYieldHistoryText:setVisible(not foundData)
- self.yieldStatsInformation:setVisible(foundData)
+ if self.noYieldHistoryText ~= nil then
+ self.noYieldHistoryText:setVisible(not foundData)
+ end
+
+ -- Keep the right detail/graph panel visible even when no data is found.
+ if self.yieldStatsInformation ~= nil then
+ self.yieldStatsInformation:setVisible(true)
+ end
+
+ if self.yieldDetailMode ~= nil then
+ self.yieldDetailMode:setVisible(true)
+ end
end
@@ -306,15 +560,17 @@ function YieldTrackerPage:setOverviewStatsText()
if selectedFieldNumText and selectedCropTypeText and selectedCropTypeIcon and selectedCropTotalHarvests and selectedCropAverageYield and selectedCropBestYear then
selectedFieldNumText:setText(string.format("%s %d", self.i18n:getText("yt_fieldIDLabel"), fieldID))
- local cropKey = cropName:lower():gsub(" ", "") .. "Label"
- selectedCropTypeText:setText(self.i18n:getText(cropKey) or cropName)
+ -- Use the already resolved registered fill type title instead of building a custom l10n key.
+ selectedCropTypeText:setText(cropName or "")
selectedCropTotalHarvests:setText(selectedOverviewData.totalYears)
selectedCropAverageYield:setText(string.format("%s l", formatWithCommas(selectedOverviewData.averageYield)))
selectedCropBestYear:setText(string.format("%s %d --> %s l", self.i18n:getText("yt_yearLabel"), selectedOverviewData.highestYear, formatWithCommas(selectedOverviewData.highestYieldAmount)))
- selectedCropTypeIcon:setImageFilename(cropIcon)
+ if cropIcon ~= nil and cropIcon ~= "" then
+ selectedCropTypeIcon:setImageFilename(cropIcon)
+ end
end
end
@@ -429,6 +685,11 @@ function YieldTrackerPage:setGraphData()
local tickContainer = self:getDescendantById("graphYearTickContainer")
local barGraph = self:getDescendantById("barGraph")
+ if tickContainer == nil or barGraph == nil or self.graphYearTickTemplate == nil or self.graphCandleTemplate == nil then
+ self:debugMessage("Graph elements are missing; skipping graph update")
+ return
+ end
+
self:clearOldElements(tickContainer, self.graphYearTickTemplate)
self:clearOldElements(barGraph, self.graphCandleTemplate)
@@ -439,42 +700,40 @@ function YieldTrackerPage:setGraphData()
local actualMaxYear = years[#years]
local displayMinYear, displayMaxYear = self:calculateDisplayRange(actualMinYear, actualMaxYear)
-
local harvestByYear = self:createHarvestLookup(self.yieldYears, displayMinYear, displayMaxYear)
local maxYield = self:findMaxYield(harvestByYear, displayMinYear, displayMaxYear)
- local minYield = self:findMinYield(harvestByYear, displayMinYear, displayMaxYear)
- local yieldRange = math.max(1, maxYield - minYield)
local numTicks = displayMaxYear - displayMinYear + 1
- local totalGraphWidth = 0.42
- local spacing = totalGraphWidth / math.max(1, numTicks - 1)
-
+ -- Matches the cleaned right-side XML graph width. Keep left selection layout untouched.
+ local graphPaddingX = 0.012
+ local graphContentWidth = 0.376
+ local spacing = graphContentWidth / math.max(1, numTicks - 1)
+ local candleWidth = 0.011
+ local minHeight = 0.035
+ local maxHeight = 0.215
for year = displayMinYear, displayMaxYear do
- local tickX = (year - displayMinYear) * spacing
+ local tickX = graphPaddingX + (year - displayMinYear) * spacing
- -- Year tick label
local tickClone = self.graphYearTickTemplate:clone(tickContainer)
tickClone:setText(tostring(year))
tickClone:setPosition(tickX, 0)
tickClone:setVisible(true)
- -- Yield candle if data exists for this year
local harvest = harvestByYear[year]
if harvest then
local yearYieldAmount = harvest.amount or 0
- if yearYieldAmount and yearYieldAmount > 0 then
+ if yearYieldAmount > 0 then
local candleClone = self.graphCandleTemplate:clone(barGraph)
- local minHeight = 0.08
- local heightRatio = (yearYieldAmount - minYield) / yieldRange
- -- heightRatio = math.sqrt(heightRatio) -- apply easing
- local candleHeight = minHeight + heightRatio * (0.27 - minHeight)
+ local heightRatio = maxYield > 0 and (yearYieldAmount / maxYield) or 0
+ heightRatio = math.max(0, math.min(1, heightRatio))
+ local candleHeight = minHeight + heightRatio * (maxHeight - minHeight)
- candleClone:setSize(0.01, candleHeight)
- candleClone:setPosition(tickX + 0.0185, 0)
+ candleClone:setSize(candleWidth, candleHeight)
+ candleClone:setPosition(tickX - (candleWidth * 0.5), 0)
candleClone:setVisible(true)
end
end
@@ -497,11 +756,11 @@ function YieldTrackerPage:populateCellForItemInSection(list, sectionIndex, index
local icon = listItem:getAttribute("icon")
if cropListText then
- local cropKey = crop.title:lower():gsub(" ", "") .. "Label"
- cropListText:setText(self.i18n:getText(cropKey) or crop.title)
+ -- Use the registered fill type title directly. No custom translation entry required.
+ cropListText:setText(crop.title or crop.name or "")
end
- if icon then
+ if icon and crop.hudOverlayFilename ~= nil and crop.hudOverlayFilename ~= "" then
icon:setImageFilename(crop.hudOverlayFilename)
end
end
diff --git a/FS25_YieldTracker/scripts/gui/yieldTrackerPage_backup.lua b/FS25_YieldTracker/scripts/gui/yieldTrackerPage_backup.lua
new file mode 100644
index 0000000..5e06caa
--- /dev/null
+++ b/FS25_YieldTracker/scripts/gui/yieldTrackerPage_backup.lua
@@ -0,0 +1,723 @@
+-- Author: BitBarn Mods
+-- Date: 06-11-2025
+-- Version: 1.0.0.0
+-- yieldTrackerPage.lua
+
+PRINT_PREFIX = "[ YieldTracker ] - "
+
+
+YieldTrackerPage = {}
+YieldTrackerPage._mt = Class(YieldTrackerPage, TabbedMenuFrameElement)
+YieldTrackerPage.debug = false
+YieldTrackerPage.dir = g_currentModDirectory
+
+-- source(g_currentModDirectory .. "scripts/yieldTrackerPage.lua") --> YieldTracker.yieldData -- BUG -- CANNOT LOAD RESOURCE, THIS IS NOT THE CORRECT PATH FOR THE DATA. TESTING NEEDED TO SEE IF REQUIRED AND IF SO CHANGE PATH
+function YieldTrackerPage.new(i18n, messageCenter)
+ local self = YieldTrackerPage:superClass().new(nil, YieldTrackerPage._mt)
+ self.name = "YieldTrackerPage"
+ self.hasCustomMenuButtons = true
+ self.i18n = i18n
+ self.messageCenter = messageCenter
+ self.dataBindings = {}
+ self.farmlandIDs = {}
+ self.yieldYears = {}
+ -- Dynamically filled from g_fruitTypeManager in onGuiSetupFinished/onFrameOpen
+ self.cropInfo = {}
+ self.selectedCrop = nil
+ self.selectedField = 1
+ self.showingOwnedFields = false
+
+
+ return self
+end
+
+
+local function formatWithCommas(n)
+ n = math.floor(n + 0.5) -- Round to nearest whole number
+ local formatted = tostring(n)
+ local k
+ while true do
+ formatted, k = formatted:gsub("^(-?%d+)(%d%d%d)", "%1,%2")
+ if k == 0 then break end
+ end
+ return formatted
+end
+
+
+function YieldTrackerPage:getLocalizedTextOrFallback(key, fallback)
+ if key ~= nil and key ~= "" then
+ local text = self.i18n:getText(key)
+ if text ~= nil and text ~= key and text ~= "" then
+ return text
+ end
+ end
+
+ return fallback or ""
+end
+
+
+function YieldTrackerPage:getFruitTypeDisplayName(fruitType, fillType)
+ local rawTitle = nil
+
+ if fillType ~= nil then
+ rawTitle = fillType.title or fillType.name
+ end
+
+ rawTitle = rawTitle or fruitType.title or fruitType.name or ""
+
+ if type(rawTitle) == "string" and rawTitle:sub(1, 6) == "$l10n_" then
+ return self:getLocalizedTextOrFallback(rawTitle:sub(7), fruitType.name or rawTitle)
+ end
+
+ return rawTitle
+end
+
+
+function YieldTrackerPage:refreshCropInfoFromFruitTypeManager()
+ local crops = {}
+ local usedFillTypes = {}
+
+ if g_fruitTypeManager == nil or g_fruitTypeManager.getFruitTypes == nil then
+ self:debugMessage("FruitTypeManager not available yet; crop list remains empty")
+ self.cropInfo = crops
+ self.selectedCrop = nil
+ return crops
+ end
+
+ for _, fruitType in ipairs(g_fruitTypeManager:getFruitTypes()) do
+ if fruitType ~= nil then
+ local fillType = fruitType.fillType
+ local fillTypeIndex = nil
+
+ if fillType ~= nil then
+ fillTypeIndex = fillType.index or fillType.fillTypeIndex
+ end
+
+ fillTypeIndex = fillTypeIndex or fruitType.fillTypeIndex
+
+ if fillTypeIndex ~= nil and usedFillTypes[fillTypeIndex] == nil then
+ usedFillTypes[fillTypeIndex] = true
+
+ local title = self:getFruitTypeDisplayName(fruitType, fillType)
+ local icon = nil
+
+ if fillType ~= nil then
+ icon = fillType.hudOverlayFilename or fillType.iconFilename
+ end
+
+ icon = icon or fruitType.hudOverlayFilename or fruitType.iconFilename or ""
+
+ table.insert(crops, {
+ fruitTypeIndex = fruitType.index,
+ fillTypeIndex = fillTypeIndex,
+ name = fruitType.name or title,
+ title = title,
+ hudOverlayFilename = icon
+ })
+ end
+ end
+ end
+
+ table.sort(crops, function(a, b)
+ return tostring(a.title):lower() < tostring(b.title):lower()
+ end)
+
+ self.cropInfo = crops
+
+ if self.selectedCrop == nil or usedFillTypes[self.selectedCrop] == nil then
+ self.selectedCrop = crops[1] ~= nil and crops[1].fillTypeIndex or nil
+ end
+
+ return crops
+end
+
+
+function YieldTrackerPage:setupCustomButtons()
+
+ self.backButtonInfo = {
+ inputAction = InputAction.MENU_BACK
+ }
+
+ self.showAllFieldButton = {
+ inputAction = InputAction.MENU_ACTIVATE,
+ text = "Show Owned Fields",
+ callback = function ()
+ self.showingOwnedFields = not self.showingOwnedFields
+
+ if self.showingOwnedFields then
+ self.showAllFieldButton.text = "Show All Fields"
+ self:onShowOwnedFieldList() -- Call your actual method
+ else
+ self.showAllFieldButton.text = "Show Owned Fields"
+ self:onShowAllFieldList()
+ end
+
+ self:setMenuButtonInfoDirty() -- Refresh the button text
+ end
+ }
+
+ local info = {
+ self.backButtonInfo,
+ self.showAllFieldButton,
+ }
+
+ self.menuButtons = info
+
+ self:setMenuButtonInfo(self.menuButtons)
+end
+
+
+function YieldTrackerPage:onShowOwnedFieldList()
+ self:getFarmlandIDs()
+ if self.farmlandList then
+ self.farmlandList:reloadData()
+ end
+end
+
+
+function YieldTrackerPage:onShowAllFieldList()
+ self:getFarmlandIDs()
+ if self.farmlandList then
+ self.farmlandList:reloadData()
+ end
+end
+
+
+function YieldTrackerPage:setButtons()
+ local info = {
+ self.backButtonInfo,
+ self.showAllFieldButton,
+ }
+
+ self.menuButtons = info
+
+ self:setMenuButtonInfoDirty()
+end
+
+
+function YieldTrackerPage:onGuiSetupFinished()
+ YieldTrackerPage:superClass().onGuiSetupFinished(self)
+
+ -- custom menu buttons
+ self:setupCustomButtons()
+
+ -- farm id list handling
+ self.farmlandList = self:getDescendantById("farmlandList")
+ if self.farmlandList ~= nil then
+ self.farmlandList:setDataSource(self)
+ self.farmlandList:setDelegate(self)
+ end
+
+ -- crop list/fill type handling
+ self.cropList = self:getDescendantById("cropList")
+ self:refreshCropInfoFromFruitTypeManager()
+ if self.cropList ~= nil then
+ self.cropList:setDataSource(self)
+ self.cropList:setDelegate(self)
+ self.cropList:reloadData()
+ end
+
+ -- users yield history list
+ self.yieldPerYearList = self:getDescendantById("yieldPerYearList")
+ if self.yieldPerYearList ~= nil then
+ self.yieldPerYearList:setDataSource(self)
+ self.yieldPerYearList:setDelegate(self)
+ end
+ self.noYieldHistoryText = self:getDescendantById("noYieldHistoryText")
+ self.yieldStatsInformation = self:getDescendantById("yieldStatsInformation")
+ self.yieldListMode = self:getDescendantById("yieldListMode")
+ self.yieldDetailMode = self:getDescendantById("yieldDetailMode")
+
+ -- Additional GUI elements used by the redesigned XML layout
+ self.yieldListMode = self:getDescendantById("yieldListMode")
+ self.yieldDetailMode = self:getDescendantById("yieldDetailMode")
+ self.noYieldHistoryText = self:getDescendantById("noYieldHistoryText")
+ self.yieldStatsInformation = self:getDescendantById("yieldStatsInformation")
+ self.graphYearTickTemplate = self:getDescendantById("graphYearTickTemplate")
+ self.graphCandleTemplate = self:getDescendantById("graphCandleTemplate")
+
+ -- debuggers
+ self:debugMessage("YieldPerYearList found: " .. tostring(self.yieldPerYearList ~= nil))
+ self:debugMessage("CropList found: " .. tostring(self.cropList ~= nil))
+ self:debugMessage("FarmlandList found: " .. tostring(self.farmlandList ~= nil))
+
+ -- Set initial search param based on self.selectedField and self.selectedCrop
+ self:setSelectedPageData()
+end
+
+
+
+function YieldTrackerPage:onFrameOpen()
+ YieldTrackerPage:superClass().onFrameOpen(self)
+ self:getFarmlandIDs()
+ self:refreshCropInfoFromFruitTypeManager()
+
+ if self.cropList then
+ self.cropList:reloadData()
+ end
+
+ if self.farmlandList then
+ self.farmlandList:reloadData()
+ FocusManager:setFocus(self.farmlandList)
+ else
+ self:debugMessage("Error: farmlandList not found in onFrameOpen")
+ end
+
+ if self.yieldPerYearList then
+ self:setSelectedPageData()
+ self.yieldPerYearList:reloadData()
+ end
+end
+
+
+function YieldTrackerPage:getFarmlandIDs()
+ local farmlandIDs = {}
+ for id, farmland in pairs(g_farmlandManager.farmlands) do
+ if farmland.showOnFarmlandsScreen then
+ local isOwned = farmland.farmId == g_currentMission:getFarmId()
+ if self.showingOwnedFields then
+ if isOwned then
+ table.insert(farmlandIDs, id)
+ end
+ else
+ table.insert(farmlandIDs, id)
+ end
+ end
+ end
+ table.sort(farmlandIDs)
+ self.farmlandIDs = farmlandIDs
+ return farmlandIDs
+end
+
+
+function YieldTrackerPage:onClickCropSelected(element)
+ local selected = self.cropInfo[element.indexInSection]
+ if selected == nil then
+ return
+ end
+
+ self.selectedCrop = selected.fillTypeIndex
+ self:setSelectedPageData()
+
+ -- NEW: switch to detail view when both selected
+ if self.selectedField ~= nil and self.selectedCrop ~= nil then
+ if self.yieldListMode ~= nil then
+ self.yieldListMode:setVisible(false)
+ end
+ if self.yieldDetailMode ~= nil then
+ self.yieldDetailMode:setVisible(true)
+ end
+ end
+end
+
+
+function YieldTrackerPage:onClickFieldSelected(element)
+ self.selectedField = self.farmlandIDs[element.indexInSection]
+ self:setSelectedPageData()
+
+ -- NEW: stay in list until crop is selected
+ if self.yieldListMode ~= nil then
+ self.yieldListMode:setVisible(true)
+ end
+ if self.yieldDetailMode ~= nil then
+ self.yieldDetailMode:setVisible(false)
+ end
+end
+
+
+function YieldTrackerPage:setSelectedPageData()
+ if self.selectedCrop == nil then
+ self.yieldYears = {}
+ return
+ end
+
+ self:initiateFieldCropStatsSearch()
+ self:setOverviewStatsText()
+
+ self:setGraphData()
+end
+
+
+function YieldTrackerPage:initiateFieldCropStatsSearch()
+ local selectedField = self.selectedField
+ local selectedFillTypeIndex = self.selectedCrop
+ local yieldData = YieldTracker.yieldData
+ local currentYear = g_currentMission.environment.currentYear
+ local foundData = false
+ self.yieldYears = {} -- Clear existing data
+
+ if selectedFillTypeIndex == nil or selectedField == nil then
+ return
+ end
+
+ self:debugMessage(string.format("Search stats for fill type index %d on field %d", selectedFillTypeIndex, selectedField))
+
+ local yearAmountMap = {}
+ local minYear = nil
+
+ -- Step 1: Collect data and find min year
+ for year, yearData in pairs(yieldData) do
+ local fieldData = yearData[selectedField]
+ if fieldData then
+ local cropData = fieldData[selectedFillTypeIndex]
+ if cropData then
+ yearAmountMap[year] = cropData.amount
+ minYear = (minYear == nil or year < minYear) and year or minYear
+ foundData = true
+
+ self:debugMessage(string.format(
+ "Year %d - Field %d - Crop: %s - Amount: %d",
+ year, selectedField, cropData.fillTypeTitle, cropData.amount
+ ))
+ else
+ self:debugMessage(string.format("Year %d - Field %d - No data for fill type index %d", year, selectedField, selectedFillTypeIndex))
+ end
+ else
+ self:debugMessage(string.format("Year %d - No data for field %d", year, selectedField))
+ end
+ end
+
+
+ -- Step 2: Fill in all years from startYear (e.g., 1) to maxYear
+ local startYear = 1 -- You can change this to the actual first possible year
+ if minYear ~= nil then
+ local maxYear = currentYear
+ -- Extend maxYear to the latest year found in data if it's greater
+ for year in pairs(yearAmountMap) do
+ if year > maxYear then
+ maxYear = year
+ end
+ end
+
+ for year = startYear, maxYear do
+ local amount = yearAmountMap[year] or 0
+ table.insert(self.yieldYears, {
+ year = year,
+ amount = amount
+ })
+ end
+ end
+
+ -- -- Sort by year ascending
+ -- table.sort(self.yieldYears, function(a, b) return a.year < b.year end)
+ -- Sort by year descending
+ table.sort(self.yieldYears, function(a, b) return a.year > b.year end)
+
+ if self.yieldPerYearList then
+ self.yieldPerYearList:reloadData()
+ end
+
+ -- Show message only if no data found
+ if self.noYieldHistoryText ~= nil then
+ self.noYieldHistoryText:setVisible(not foundData)
+ end
+
+ -- Keep the right detail/graph panel visible even when no data is found.
+ if self.yieldStatsInformation ~= nil then
+ self.yieldStatsInformation:setVisible(true)
+ end
+
+ if self.yieldDetailMode ~= nil then
+ self.yieldDetailMode:setVisible(true)
+ end
+end
+
+
+function YieldTrackerPage:setOverviewStatsText()
+ local cropName = ""
+ local cropIcon = ""
+ local fieldID = self.selectedField or 0
+
+ -- Get crop name from fillTypeIndex
+ for _, crop in ipairs(self.cropInfo) do
+ if crop.fillTypeIndex == self.selectedCrop then
+ cropName = crop.title
+ cropIcon = crop.hudOverlayFilename
+ break
+ end
+ end
+
+ local pageIcon = Utils.getFilename("images/page_header_icon.dds", YieldTrackerPage.dir)
+ local pageHeaderIcon = self:getDescendantById("pageHeaderIcon")
+ pageHeaderIcon:setImageFilename(pageIcon)
+
+ local selectedOverviewData = self:fetchOverviewStats()
+ local selectedFieldNumText = self:getDescendantById("selectedFieldNumText")
+ local selectedCropTypeText = self:getDescendantById("selectedCropTypeText")
+ local selectedCropTypeIcon = self:getDescendantById("selectedCropicon")
+ local selectedCropTotalHarvests = self:getDescendantById("selectedCropTotalHarvests")
+ local selectedCropAverageYield = self:getDescendantById("selectedCropAverageYield")
+ local selectedCropBestYear = self:getDescendantById("selectedCropBestYear")
+
+ if selectedFieldNumText and selectedCropTypeText and selectedCropTypeIcon and selectedCropTotalHarvests and selectedCropAverageYield and selectedCropBestYear then
+ selectedFieldNumText:setText(string.format("%s %d", self.i18n:getText("yt_fieldIDLabel"), fieldID))
+
+ -- Use the already resolved registered fill type title instead of building a custom l10n key.
+ selectedCropTypeText:setText(cropName or "")
+
+ selectedCropTotalHarvests:setText(selectedOverviewData.totalYears)
+ selectedCropAverageYield:setText(string.format("%s l", formatWithCommas(selectedOverviewData.averageYield)))
+
+ selectedCropBestYear:setText(string.format("%s %d --> %s l", self.i18n:getText("yt_yearLabel"), selectedOverviewData.highestYear, formatWithCommas(selectedOverviewData.highestYieldAmount)))
+
+ if cropIcon ~= nil and cropIcon ~= "" then
+ selectedCropTypeIcon:setImageFilename(cropIcon)
+ end
+ end
+end
+
+
+function YieldTrackerPage:fetchOverviewStats()
+ local totalYears = 0
+ local totalAmount = 0
+ local totalEntries = 0
+ local highestYieldAmount = 0
+ local highestYear = 0
+
+ for _, entry in ipairs(self.yieldYears) do
+ local yearYieldAmount = entry.amount or 0
+
+ if yearYieldAmount > 0 then
+ totalAmount = totalAmount + yearYieldAmount
+ totalEntries = totalEntries + 1
+ totalYears = totalYears + 1
+
+ if yearYieldAmount > highestYieldAmount then
+ highestYieldAmount = yearYieldAmount
+ highestYear = entry.year
+ end
+ end
+ end
+
+ local averageYield = totalEntries > 0 and (totalAmount / totalEntries) or 0
+
+ return {
+ totalYears = totalYears,
+ averageYield = averageYield,
+ highestYear = highestYear,
+ highestYieldAmount = highestYieldAmount
+ }
+end
+
+
+-- SET GRAPH FUNCTIONALITY
+function YieldTrackerPage:clearOldElements(container, template)
+ for i = #container.elements, 1, -1 do
+ local child = container.elements[i]
+ if child ~= template then
+ container:removeElement(child)
+ end
+ end
+end
+
+
+function YieldTrackerPage:extractYears(data)
+ local years = {}
+ for _, harvest in ipairs(data) do
+ if harvest.year then
+ table.insert(years, harvest.year)
+ end
+ end
+ table.sort(years)
+ return years
+end
+
+
+function YieldTrackerPage:calculateDisplayRange(minYear, maxYear)
+ local rangeYears = maxYear - minYear + 1
+ local displayMinYear, displayMaxYear
+ if rangeYears >= 20 then
+ displayMaxYear = maxYear
+ displayMinYear = displayMaxYear - 19
+ else
+ displayMinYear = minYear
+ displayMaxYear = displayMinYear + 19
+ end
+ return displayMinYear, displayMaxYear
+end
+
+
+function YieldTrackerPage:createHarvestLookup(data, minYear, maxYear)
+ local lookup = {}
+ for _, harvest in ipairs(data) do
+ if harvest.year and harvest.year >= minYear and harvest.year <= maxYear then
+ lookup[harvest.year] = harvest
+ end
+ end
+ return lookup
+end
+
+
+function YieldTrackerPage:findMaxYield(harvestByYear, minYear, maxYear)
+ local maxYield = 0
+ for year = minYear, maxYear do
+ local h = harvestByYear[year]
+ if h and h.amount and h.amount > maxYield then
+ maxYield = h.amount
+ end
+ end
+ return maxYield
+end
+
+function YieldTrackerPage:findMinYield(harvestByYear, startYear, endYear)
+ local min = math.huge
+ for year = startYear, endYear do
+ local data = harvestByYear[year]
+ if data and data.amount and data.amount < min then
+ min = data.amount
+ end
+ end
+ return min
+end
+
+
+function YieldTrackerPage:setGraphData()
+ if not self.yieldYears or #self.yieldYears == 0 then return end
+
+ local tickContainer = self:getDescendantById("graphYearTickContainer")
+ local barGraph = self:getDescendantById("barGraph")
+
+ if tickContainer == nil or barGraph == nil or self.graphYearTickTemplate == nil or self.graphCandleTemplate == nil then
+ self:debugMessage("Graph elements are missing; skipping graph update")
+ return
+ end
+
+ self:clearOldElements(tickContainer, self.graphYearTickTemplate)
+ self:clearOldElements(barGraph, self.graphCandleTemplate)
+
+ local years = self:extractYears(self.yieldYears)
+ if #years == 0 then return end
+
+ local actualMinYear = years[1]
+ local actualMaxYear = years[#years]
+
+ local displayMinYear, displayMaxYear = self:calculateDisplayRange(actualMinYear, actualMaxYear)
+ local harvestByYear = self:createHarvestLookup(self.yieldYears, displayMinYear, displayMaxYear)
+
+ local maxYield = self:findMaxYield(harvestByYear, displayMinYear, displayMaxYear)
+
+ local numTicks = displayMaxYear - displayMinYear + 1
+
+ -- Matches the cleaned right-side XML graph width. Keep left selection layout untouched.
+ local graphPaddingX = 0.012
+ local graphContentWidth = 0.376
+ local spacing = graphContentWidth / math.max(1, numTicks - 1)
+ local candleWidth = 0.011
+ local minHeight = 0.035
+ local maxHeight = 0.215
+
+ for year = displayMinYear, displayMaxYear do
+ local tickX = graphPaddingX + (year - displayMinYear) * spacing
+
+ local tickClone = self.graphYearTickTemplate:clone(tickContainer)
+ tickClone:setText(tostring(year))
+ tickClone:setPosition(tickX, 0)
+ tickClone:setVisible(true)
+
+ local harvest = harvestByYear[year]
+ if harvest then
+ local yearYieldAmount = harvest.amount or 0
+
+ if yearYieldAmount > 0 then
+ local candleClone = self.graphCandleTemplate:clone(barGraph)
+ local heightRatio = maxYield > 0 and (yearYieldAmount / maxYield) or 0
+ heightRatio = math.max(0, math.min(1, heightRatio))
+ local candleHeight = minHeight + heightRatio * (maxHeight - minHeight)
+
+ candleClone:setSize(candleWidth, candleHeight)
+ candleClone:setPosition(tickX - (candleWidth * 0.5), 0)
+ candleClone:setVisible(true)
+ end
+ end
+ end
+end
+-- SET GRAPH FUNCTIONALITY - END
+
+
+function YieldTrackerPage:populateCellForItemInSection(list, sectionIndex, index, listItem)
+ if list == self.farmlandList then
+ local farmlandID = self.farmlandIDs[index]
+ local fieldIDListText = listItem:getAttribute("fieldIDListText")
+ if fieldIDListText then
+ fieldIDListText:setText(string.format("%s %d", self.i18n:getText("yt_fieldIDLabel"), farmlandID))
+ end
+ elseif list == self.cropList then
+ local crop = self.cropInfo[index]
+ if crop then
+ local cropListText = listItem:getAttribute("cropListText")
+ local icon = listItem:getAttribute("icon")
+
+ if cropListText then
+ -- Use the registered fill type title directly. No custom translation entry required.
+ cropListText:setText(crop.title or crop.name or "")
+ end
+
+ if icon and crop.hudOverlayFilename ~= nil and crop.hudOverlayFilename ~= "" then
+ icon:setImageFilename(crop.hudOverlayFilename)
+ end
+ end
+ elseif list == self.yieldPerYearList then
+ local entry = self.yieldYears[index]
+ if entry then
+ local yearText = listItem:getDescendantByName("year")
+ local amountText = listItem:getDescendantByName("yieldAmt")
+ if yearText then
+ yearText:setText(string.format("%s %s", self.i18n:getText("yt_yearLabel"), tostring(entry.year)))
+ end
+ if amountText then
+ amountText:setText(formatWithCommas(entry.amount) .. " l")
+ end
+ end
+ end
+end
+
+
+
+function YieldTrackerPage:getCellTypeForItemInSection(_, sectionIndex, index)
+ return "default"
+end
+
+
+function YieldTrackerPage:getNumberOfItemsInSection(list, sectionIndex)
+ if list == self.farmlandList then
+ return #self.farmlandIDs
+ elseif list == self.cropList then
+ return #self.cropInfo
+ elseif list == self.yieldPerYearList then
+ return #self.yieldYears
+ end
+ return 0
+end
+
+
+
+function YieldTrackerPage:debugMessage(message)
+ if self.debug then
+ print(PRINT_PREFIX .. message)
+ end
+end
+
+
+-- DEBUG
+-- function dump(o, indent, depth)
+-- indent = indent or ""
+-- depth = depth or 1
+
+-- if type(o) ~= "table" then
+-- return tostring(o)
+-- elseif depth <= 0 then
+-- return "{...}"
+-- end
+
+-- local s = "{\n"
+-- for k, v in pairs(o) do
+-- s = s .. indent .. " [" .. tostring(k) .. "] = "
+-- if type(v) == "table" then
+-- s = s .. dump(v, indent .. " ", depth - 1)
+-- else
+-- s = s .. tostring(v)
+-- end
+-- s = s .. "\n"
+-- end
+-- return s .. indent .. "}"
+-- end
diff --git a/FS25_YieldTracker/scripts/yieldTracker.lua b/FS25_YieldTracker/scripts/yieldTracker.lua
index c81f492..97e2c3e 100644
--- a/FS25_YieldTracker/scripts/yieldTracker.lua
+++ b/FS25_YieldTracker/scripts/yieldTracker.lua
@@ -12,56 +12,17 @@ YieldTracker = {
saveFileName = "yieldTrackerData.xml",
}
YieldTracker.debug = false
+YieldTracker.debugHarvest = false
---- Current active crop fill types that are tracked
-source(g_currentModDirectory .. "scripts/trackedCropTypes.lua") --> TrackedCropTypes
-local trackedCropTypes = TrackedCropTypes
-
+--- Active crop fill types are resolved dynamically from g_fruitTypeManager.
-- Called every frame to update yield tracking for all combines in the current mission.
-- Iterates over all vehicles, checks for combines, determines active fill types, and tracks their fill units.
function YieldTracker:updateByFrame()
- local currentVehicles = {}
-
- for _, vehicle in pairs(g_currentMission.vehicleSystem.vehicles) do
- if vehicle.spec_combine then
- -- Track current vehicle ids
- currentVehicles[vehicle.id] = true
-
- local cutterInputFillType, cutterOutputFillType = self:getCutterStatus(vehicle)
- local hasInternalFillUnit, combineFillType, activeFillLevel = self:getCombineStatus(vehicle)
- local fieldFillType = self:getFieldFillTypeFallback(vehicle)
-
- local activeFillType = hasInternalFillUnit
- and self:getActiveFillType(cutterInputFillType, combineFillType, fieldFillType)
- or self:getActiveFillType(cutterOutputFillType, combineFillType, fieldFillType)
-
- local isBaler = false
- self:trackFillUnit(vehicle, activeFillType, activeFillLevel, isBaler)
-
- end
-
- if vehicle.spec_baler then
- currentVehicles[vehicle.id] = true
- local balerFillType = vehicle:getFillUnitFillType(1)
- local balerFillLevel = vehicle:getFillUnitFillLevel(1)
- local isbaler = true
- self:trackFillUnit(vehicle, balerFillType, balerFillLevel, isbaler)
- end
-
- end
-
- -- Remove vehicles from prevFillLevels that are no longer in game/sold
- if self.prevFillLevels then
- for vehicleId in pairs(self.prevFillLevels) do
- if not currentVehicles[vehicleId] then
- self.prevFillLevels[vehicleId] = nil
- self:debugMessage(string.format("Removed stale vehicle %d from tracking", vehicleId))
- end
- end
- end
+ -- Harvest amounts are recorded by Combine.onUpdateTick tank deltas and Dischargeable.onUpdateTick pipe deltas.
+ -- The old per-frame scan is intentionally disabled.
end
@@ -132,6 +93,289 @@ end
+-- EVENT-BASED HARVEST TRACKING
+--=======================================================================================================================================================================
+
+function YieldTracker:installFillUnitHook()
+ self:debugHarvestMessage("installFillUnitHook() called")
+
+ if self.fillUnitHookInstalled then
+ self:debugHarvestMessage("installFillUnitHook() skipped: already installed")
+ return
+ end
+
+ self:debugHarvestMessage("Vehicle exists=" .. tostring(Vehicle ~= nil))
+ self:debugHarvestMessage("Vehicle.addFillUnitFillLevel exists=" .. tostring(Vehicle ~= nil and Vehicle.addFillUnitFillLevel ~= nil))
+ self:debugHarvestMessage("FillUnit exists=" .. tostring(FillUnit ~= nil))
+ self:debugHarvestMessage("FillUnit.addFillUnitFillLevel exists=" .. tostring(FillUnit ~= nil and FillUnit.addFillUnitFillLevel ~= nil))
+
+ local tracker = self
+ local installedAnyHook = false
+
+ if Vehicle ~= nil and Vehicle.addFillUnitFillLevel ~= nil then
+ Vehicle.addFillUnitFillLevel = Utils.overwrittenFunction(Vehicle.addFillUnitFillLevel, function(vehicle, superFunc, farmId, fillUnitIndex, fillLevelDelta, fillTypeIndex, toolType, fillPositionData, ...)
+ tracker:debugHarvestMessage(string.format(
+ "HOOK_CALL Vehicle.addFillUnitFillLevel vehicle='%s' vehicleId=%s farmId=%s fillUnitIndex=%s delta=%s fillTypeIndex=%s",
+ tracker:getVehicleDebugName(vehicle),
+ tostring(vehicle ~= nil and vehicle.id or nil),
+ tostring(farmId),
+ tostring(fillUnitIndex),
+ tostring(fillLevelDelta),
+ tostring(fillTypeIndex)
+ ))
+
+ local oldFillLevel = nil
+ if vehicle ~= nil and fillUnitIndex ~= nil and vehicle.getFillUnitFillLevel ~= nil then
+ oldFillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex) or 0
+ end
+
+ local result = superFunc(vehicle, farmId, fillUnitIndex, fillLevelDelta, fillTypeIndex, toolType, fillPositionData, ...)
+
+ local appliedAmount = fillLevelDelta
+ if type(result) == "number" then
+ appliedAmount = result
+ elseif oldFillLevel ~= nil and vehicle ~= nil and vehicle.getFillUnitFillLevel ~= nil then
+ local newFillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex) or oldFillLevel
+ local measuredDelta = newFillLevel - oldFillLevel
+ tracker:debugHarvestMessage(string.format(
+ "HOOK_AFTER Vehicle.addFillUnitFillLevel old=%s new=%s measuredDelta=%s result=%s",
+ tostring(oldFillLevel), tostring(newFillLevel), tostring(measuredDelta), tostring(result)
+ ))
+ if measuredDelta > 0 then
+ appliedAmount = measuredDelta
+ end
+ end
+
+ tracker:handleHarvestFillUnitAdded(vehicle, fillUnitIndex, appliedAmount, fillTypeIndex)
+ return result
+ end)
+
+ installedAnyHook = true
+ self:debugHarvestMessage("Installed hook: Vehicle.addFillUnitFillLevel")
+ end
+
+ -- Additional trace hook in case FS25 calls the specialization table directly instead of Vehicle.*
+ if FillUnit ~= nil and FillUnit.addFillUnitFillLevel ~= nil and (Vehicle == nil or FillUnit.addFillUnitFillLevel ~= Vehicle.addFillUnitFillLevel) then
+ FillUnit.addFillUnitFillLevel = Utils.overwrittenFunction(FillUnit.addFillUnitFillLevel, function(vehicle, superFunc, farmId, fillUnitIndex, fillLevelDelta, fillTypeIndex, toolType, fillPositionData, ...)
+ tracker:debugHarvestMessage(string.format(
+ "HOOK_CALL FillUnit.addFillUnitFillLevel vehicle='%s' vehicleId=%s farmId=%s fillUnitIndex=%s delta=%s fillTypeIndex=%s",
+ tracker:getVehicleDebugName(vehicle),
+ tostring(vehicle ~= nil and vehicle.id or nil),
+ tostring(farmId),
+ tostring(fillUnitIndex),
+ tostring(fillLevelDelta),
+ tostring(fillTypeIndex)
+ ))
+
+ local oldFillLevel = nil
+ if vehicle ~= nil and fillUnitIndex ~= nil and vehicle.getFillUnitFillLevel ~= nil then
+ oldFillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex) or 0
+ end
+
+ local result = superFunc(vehicle, farmId, fillUnitIndex, fillLevelDelta, fillTypeIndex, toolType, fillPositionData, ...)
+
+ local appliedAmount = fillLevelDelta
+ if type(result) == "number" then
+ appliedAmount = result
+ elseif oldFillLevel ~= nil and vehicle ~= nil and vehicle.getFillUnitFillLevel ~= nil then
+ local newFillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex) or oldFillLevel
+ local measuredDelta = newFillLevel - oldFillLevel
+ tracker:debugHarvestMessage(string.format(
+ "HOOK_AFTER FillUnit.addFillUnitFillLevel old=%s new=%s measuredDelta=%s result=%s",
+ tostring(oldFillLevel), tostring(newFillLevel), tostring(measuredDelta), tostring(result)
+ ))
+ if measuredDelta > 0 then
+ appliedAmount = measuredDelta
+ end
+ end
+
+ tracker:handleHarvestFillUnitAdded(vehicle, fillUnitIndex, appliedAmount, fillTypeIndex)
+ return result
+ end)
+
+ installedAnyHook = true
+ self:debugHarvestMessage("Installed hook: FillUnit.addFillUnitFillLevel")
+ end
+
+ if not installedAnyHook then
+ self:debugHarvestMessage("ERROR: no fill unit hook installed; no supported addFillUnitFillLevel function found")
+ self:debugMessage("Could not install fill unit hook: no addFillUnitFillLevel function found")
+ return
+ end
+
+ self.fillUnitHookInstalled = true
+ self:debugHarvestMessage("installFillUnitHook() finished")
+ self:debugMessage("Installed fill unit harvest hook")
+end
+
+function YieldTracker:getVehicleDebugName(vehicle)
+ if vehicle == nil then
+ return "nil"
+ end
+
+ if vehicle.getName ~= nil then
+ local ok, name = pcall(function() return vehicle:getName() end)
+ if ok and name ~= nil and name ~= "" then
+ return tostring(name)
+ end
+ end
+
+ if vehicle.configFileName ~= nil then
+ return tostring(vehicle.configFileName)
+ end
+
+ if vehicle.typeName ~= nil then
+ return tostring(vehicle.typeName)
+ end
+
+ return tostring(vehicle)
+end
+
+function YieldTracker:debugHarvestMessage(message)
+ if self.debugHarvest then
+ print(PRINT_PREFIX .. "HARVEST_DEBUG - " .. tostring(message))
+ end
+end
+
+function YieldTracker:debugHarvestDecision(vehicle, fieldID, fillTypeIndex, amount, reason)
+ if not self.debugHarvest then
+ return
+ end
+
+ local vehicleName = self:getVehicleDebugName(vehicle)
+ local fillTypeName = "nil"
+ if fillTypeIndex ~= nil and g_fillTypeManager ~= nil and g_fillTypeManager.indexToFillType ~= nil then
+ local desc = g_fillTypeManager.indexToFillType[fillTypeIndex]
+ if desc ~= nil then
+ fillTypeName = tostring(desc.title or desc.name or fillTypeIndex)
+ else
+ fillTypeName = tostring(fillTypeIndex)
+ end
+ end
+
+ self:debugHarvestMessage(string.format(
+ "vehicle='%s' vehicleId=%s combine=%s baler=%s field=%s fillType=%s(%s) amount=%.2f reason=%s",
+ vehicleName,
+ tostring(vehicle ~= nil and vehicle.id or nil),
+ tostring(vehicle ~= nil and vehicle.spec_combine ~= nil),
+ tostring(vehicle ~= nil and vehicle.spec_baler ~= nil),
+ tostring(fieldID),
+ fillTypeName,
+ tostring(fillTypeIndex),
+ tonumber(amount or 0) or 0,
+ tostring(reason)
+ ))
+end
+
+function YieldTracker:handleHarvestFillUnitAdded(vehicle, fillUnitIndex, fillLevelDelta, fillTypeIndex)
+ local rawAmount = tonumber(fillLevelDelta or 0) or 0
+
+ if not self.yieldDataLoaded then
+ self:debugHarvestDecision(vehicle, nil, fillTypeIndex, rawAmount, "SKIP_yieldData_not_loaded")
+ return
+ end
+
+ if vehicle == nil or fillUnitIndex == nil or fillLevelDelta == nil or rawAmount <= 0 then
+ self:debugHarvestDecision(vehicle, nil, fillTypeIndex, rawAmount, "SKIP_invalid_or_non_positive_delta")
+ return
+ end
+
+ local isCombine = vehicle.spec_combine ~= nil
+ local isBaler = vehicle.spec_baler ~= nil
+
+ -- Count only where crop is generated. Ignore trailer/silo/transfer additions.
+ if not isCombine and not isBaler then
+ self:debugHarvestDecision(vehicle, nil, fillTypeIndex, rawAmount, "SKIP_not_combine_or_baler")
+ return
+ end
+
+ local fieldID = self:getHarvestFieldID(vehicle, isBaler)
+
+ if isCombine and not self:getIsCombineHarvesting(vehicle) then
+ self:debugHarvestDecision(vehicle, fieldID, fillTypeIndex, rawAmount, "SKIP_combine_not_harvesting")
+ return
+ end
+
+ if isBaler and not self:getIsBalerHarvesting(vehicle) then
+ self:debugHarvestDecision(vehicle, fieldID, fillTypeIndex, rawAmount, "SKIP_baler_not_harvesting")
+ return
+ end
+
+ local activeFillType = fillTypeIndex
+ if (activeFillType == nil or activeFillType == FillType.UNKNOWN) and vehicle.getFillUnitFillType ~= nil then
+ activeFillType = vehicle:getFillUnitFillType(fillUnitIndex)
+ end
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = self:getFieldFillTypeFallback(vehicle)
+ end
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "SKIP_no_active_fillType")
+ return
+ end
+
+ if not self:isTrackedFillType(activeFillType) then
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "SKIP_fillType_not_registered_fruit")
+ return
+ end
+
+ if fieldID == nil then
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "SKIP_no_fieldID")
+ return
+ end
+
+ local fillTypeDesc = g_fillTypeManager.indexToFillType[activeFillType]
+ if fillTypeDesc == nil then
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "SKIP_no_fillTypeDesc")
+ return
+ end
+
+ local currentYear = g_currentMission.environment.currentYear
+ self:recordHarvest(currentYear, fieldID, fillTypeDesc.title, rawAmount, activeFillType)
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "RECORDED")
+end
+
+function YieldTracker:getIsCombineHarvesting(vehicle)
+ if vehicle == nil or vehicle.spec_combine == nil then
+ return false
+ end
+
+ local spec = vehicle.spec_combine
+ if spec.attachedCutters ~= nil then
+ for _, cutter in pairs(spec.attachedCutters) do
+ if cutter ~= nil and cutter.spec_cutter ~= nil and cutter.spec_cutter.isWorking then
+ return true
+ end
+ end
+ end
+
+ return false
+end
+
+function YieldTracker:getIsBalerHarvesting(vehicle)
+ if vehicle == nil or vehicle.spec_baler == nil then
+ return false
+ end
+
+ if vehicle.getIsTurnedOn ~= nil and vehicle:getIsTurnedOn() then
+ return true
+ end
+
+ if vehicle.getLastSpeed ~= nil and vehicle:getLastSpeed() > 0.5 then
+ return true
+ end
+
+ return true
+end
+
+function YieldTracker:getHarvestFieldID(vehicle, isBaler)
+ if isBaler then
+ local x, _, z = getWorldTranslation(vehicle.rootNode)
+ return g_farmlandManager:getFarmlandIdAtWorldPosition(x, z)
+ end
+
+ return self:getCutterFieldID(vehicle)
+end
+
-- UTIL METHODS
--=======================================================================================================================================================================
@@ -252,11 +496,29 @@ end
-- Checks if a given fill type index is in the tracked crop types.
function YieldTracker:isTrackedFillType(fillTypeIndex)
- for _, crop in ipairs(trackedCropTypes) do
- if crop.fillTypeIndex == fillTypeIndex then
- return true
+ if fillTypeIndex == nil then
+ return false
+ end
+
+ if g_fruitTypeManager == nil or g_fruitTypeManager.getFruitTypes == nil then
+ return false
+ end
+
+ for _, fruitType in ipairs(g_fruitTypeManager:getFruitTypes()) do
+ if fruitType ~= nil then
+ local fillType = fruitType.fillType
+ local fruitFillTypeIndex = fruitType.fillTypeIndex
+
+ if fillType ~= nil then
+ fruitFillTypeIndex = fillType.index or fillType.fillTypeIndex or fruitFillTypeIndex
+ end
+
+ if fruitFillTypeIndex == fillTypeIndex then
+ return true
+ end
end
end
+
return false
end
@@ -297,129 +559,1183 @@ end
--- SAVE/LOAD METHODS
---=======================================================================================================================================================================
--- Call required dependencies upon loadmap from main.lua
-function YieldTracker:setup()
- self:loadYieldData()
- self:hookInGameSaveEvent()
-end
+-- COMBINE FILL LEVEL DELTA DEBUG HOOK
+--=======================================================================================================================================================================
+function YieldTracker:installCombineFillLevelDeltaDebugHook()
+ self:debugHarvestMessage("installCombineFillLevelDeltaDebugHook() called")
+ if self.combineFillLevelDeltaDebugHookInstalled then
+ self:debugHarvestMessage("installCombineFillLevelDeltaDebugHook() skipped: already installed")
+ return
+ end
--- Hooks into the save event to trigger yield data saving on every game save.
-function YieldTracker:hookInGameSaveEvent()
- FSCareerMissionInfo.saveToXMLFile = Utils.appendedFunction(
- FSCareerMissionInfo.saveToXMLFile,
- function() YieldTracker:saveYieldData() end)
-end
+ self.combineFillLevelDebugCallCount = self.combineFillLevelDebugCallCount or {}
+ self.combineFillLevelDebugMaxIdleLogsPerVehicle = 25
+ self.combinePreviousFillLevels = self.combinePreviousFillLevels or {}
+ self.combinePreviousFillTypes = self.combinePreviousFillTypes or {}
+ self:debugHarvestMessage("DELTA_EXISTS Combine=" .. tostring(Combine ~= nil))
+ self:debugHarvestMessage("DELTA_EXISTS Combine.onUpdateTick=" .. tostring(Combine ~= nil and type(Combine.onUpdateTick) == "function"))
+ self:debugHarvestMessage("DELTA_EXISTS Combine.updateTick=" .. tostring(Combine ~= nil and type(Combine.updateTick) == "function"))
--- Loads yield tracking data and vehicle baseline fill levels from an XML file in the current savegame directory.
--- Rebuilds the yieldData structure by year, field, and crop, and restores previous fill levels per vehicle.
--- If no file exists or loading fails, initializes with empty data and marks data as loaded.
-function YieldTracker:loadYieldData()
- if not g_currentMission or not g_currentMission.missionInfo.savegameDirectory then
- self:debugMessage("Load skipped: no savegame directory")
- self.yieldDataLoaded = true
+ if Combine == nil or type(Combine.onUpdateTick) ~= "function" then
+ self:debugHarvestMessage("ERROR: Combine.onUpdateTick not found; combine delta debug hook not installed")
return
end
- local filePath = g_currentMission.missionInfo.savegameDirectory .. "/" .. self.saveFileName
- if not fileExists(filePath) then
- self:debugMessage("No existing yield data XML found")
- self.yieldDataLoaded = true
- return
+ local tracker = self
+
+ Combine.onUpdateTick = Utils.overwrittenFunction(Combine.onUpdateTick, function(vehicle, superFunc, dt, ...)
+ local result = superFunc(vehicle, dt, ...)
+
+ -- Compare against the previous onUpdateTick call, not against a value
+ -- read earlier in the same tick. In FS25 the combine fill level can
+ -- already be updated before Combine.onUpdateTick is entered.
+ local currentLevel, currentFillType, fillUnitIndex = tracker:getCombineInternalFillState(vehicle)
+ local vehicleId = vehicle ~= nil and (vehicle.id or tostring(vehicle)) or nil
+ local key = nil
+
+ if vehicleId ~= nil and fillUnitIndex ~= nil then
+ key = tostring(vehicleId) .. ":" .. tostring(fillUnitIndex)
+ end
+
+ local previousLevel = nil
+ local previousFillType = nil
+
+ if key ~= nil then
+ previousLevel = tracker.combinePreviousFillLevels[key]
+ previousFillType = tracker.combinePreviousFillTypes[key]
+ end
+
+ local delta = nil
+ if previousLevel ~= nil and currentLevel ~= nil then
+ delta = currentLevel - previousLevel
+ end
+
+ tracker:handleCombineFillLevelDeltaDebug(vehicle, fillUnitIndex, previousLevel, currentLevel, delta, previousFillType, currentFillType)
+
+ -- Update baseline only after logging/recording.
+ if key ~= nil and currentLevel ~= nil then
+ tracker.combinePreviousFillLevels[key] = currentLevel
+ tracker.combinePreviousFillTypes[key] = currentFillType
+ end
+
+ return result
+ end)
+
+ self.combineFillLevelDeltaDebugHookInstalled = true
+ self:debugHarvestMessage("DELTA_INSTALLED Combine.onUpdateTick")
+ self:debugHarvestMessage("installCombineFillLevelDeltaDebugHook() finished")
+end
+
+function YieldTracker:getCombineInternalFillState(vehicle)
+ if vehicle == nil or vehicle.spec_combine == nil then
+ return nil, nil, nil
end
- local xmlFile = loadXMLFile("YieldDataXML", filePath)
- if not xmlFile then
- self:debugMessage("Failed to load yield data XML")
- return
+ local spec = vehicle.spec_combine
+ local fillUnitIndex = spec.fillUnitIndex
+ if fillUnitIndex == nil then
+ return nil, nil, nil
end
- self.yieldData = {}
+ local fillLevel = nil
+ local fillType = nil
- local i = 0
- while true do
- local yearKey = string.format("yieldData.year(%d)", i)
- if not hasXMLProperty(xmlFile, yearKey) then break end
+ if vehicle.getFillUnitFillLevel ~= nil then
+ fillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex)
+ end
- local year = getXMLInt(xmlFile, yearKey .. "#value")
- self.yieldData[year] = {}
+ if vehicle.getFillUnitFillType ~= nil then
+ fillType = vehicle:getFillUnitFillType(fillUnitIndex)
+ end
- local j = 0
- while true do
- local fieldKey = string.format("%s.field(%d)", yearKey, j)
- if not hasXMLProperty(xmlFile, fieldKey) then break end
+ return fillLevel, fillType, fillUnitIndex
+end
- local fieldId = getXMLInt(xmlFile, fieldKey .. "#id")
- self.yieldData[year][fieldId] = {}
+function YieldTracker:handleCombineFillLevelDeltaDebug(vehicle, fillUnitIndex, oldLevel, newLevel, delta, oldFillType, newFillType)
+ if not self.debugHarvest then
+ return
+ end
- local k = 0
- while true do
- local cropKey = string.format("%s.crop(%d)", fieldKey, k)
- if not hasXMLProperty(xmlFile, cropKey) then break end
+ if vehicle == nil then
+ self:debugHarvestMessage("DELTA_SKIP vehicle=nil")
+ return
+ end
- local cropName = getXMLString(xmlFile, cropKey .. "#name")
- local amount = getXMLFloat(xmlFile, cropKey .. "#amount")
- local fillTypeIndex = getXMLInt(xmlFile, cropKey .. "#fillTypeIndex")
+ local vehicleId = vehicle.id or tostring(vehicle)
+ self.combineFillLevelDebugCallCount[vehicleId] = (self.combineFillLevelDebugCallCount[vehicleId] or 0) + 1
- self.yieldData[year][fieldId][fillTypeIndex] = {
- amount = amount,
- fillTypeTitle = cropName
- }
+ local fieldID = self:getCutterFieldID(vehicle)
+ local cutterInputFillType, cutterOutputFillType = self:getCutterStatus(vehicle)
+ local activeFillType = self:getActiveFillType(newFillType, cutterOutputFillType, cutterInputFillType)
- k = k + 1
- end
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = self:getFieldFillTypeFallback(vehicle)
+ end
- j = j + 1
- end
+ local fillTypeTitle = self:getFillTypeDebugTitle(activeFillType)
+ local vehicleName = self:getVehicleDebugName(vehicle)
+ local amount = tonumber(delta or 0) or 0
- i = i + 1
+ if newLevel == nil then
+ self:debugHarvestMessage(string.format(
+ "DELTA_SKIP vehicle='%s' vehicleId=%s field=%s fillUnit=%s old=%s new=%s delta=%s activeFillType=%s(%s) reason=no_current_fill_state",
+ vehicleName, tostring(vehicleId), tostring(fieldID), tostring(fillUnitIndex), tostring(oldLevel), tostring(newLevel), tostring(delta), fillTypeTitle, tostring(activeFillType)
+ ))
+ return
end
- -- Load prevFillLevels baseline per vehicle
- self.prevFillLevels = {}
- local v = 0
- while true do
- local vehicleKey = string.format("yieldData.prevFillLevels.vehicle(%d)", v)
- if not hasXMLProperty(xmlFile, vehicleKey) then break end
+ if oldLevel == nil then
+ self:debugHarvestMessage(string.format(
+ "COMBINE_DELTA_BASELINE vehicle='%s' vehicleId=%s field=%s fillUnit=%s current=%.2f newFillType=%s activeFillType=%s(%s) reason=baseline_initialized",
+ vehicleName,
+ tostring(vehicleId),
+ tostring(fieldID),
+ tostring(fillUnitIndex),
+ tonumber(newLevel or 0) or 0,
+ tostring(newFillType),
+ fillTypeTitle,
+ tostring(activeFillType)
+ ))
+ return
+ end
- local vehicleId = getXMLInt(xmlFile, vehicleKey .. "#id")
- local level = getXMLFloat(xmlFile, vehicleKey .. "#level")
+ if amount > 0.001 then
+ local reason = "DELTA_POSITIVE"
+ local recorded = false
+
+ if not self.yieldDataLoaded then
+ reason = "DELTA_POSITIVE_SKIP_yieldData_not_loaded"
+ elseif fieldID == nil then
+ reason = "DELTA_POSITIVE_SKIP_no_fieldID"
+ elseif activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ reason = "DELTA_POSITIVE_SKIP_no_active_fillType"
+ elseif not self:isTrackedFillType(activeFillType) then
+ reason = "DELTA_POSITIVE_SKIP_fillType_not_registered_fruit"
+ else
+ local fillTypeDesc = g_fillTypeManager.indexToFillType[activeFillType]
+ local cropName = fillTypeDesc ~= nil and (fillTypeDesc.title or fillTypeDesc.name or tostring(activeFillType)) or tostring(activeFillType)
+ local currentYear = g_currentMission.environment.currentYear
+ self:recordHarvest(currentYear, fieldID, cropName, amount, activeFillType)
+ reason = "DELTA_POSITIVE_RECORDED"
+ recorded = true
+ end
- if vehicleId and level then
- self.prevFillLevels[vehicleId] = level
+ self:debugHarvestMessage(string.format(
+ "COMBINE_DELTA vehicle='%s' vehicleId=%s field=%s fillUnit=%s old=%.2f new=%.2f delta=%.2f oldFillType=%s newFillType=%s cutterInput=%s cutterOutput=%s activeFillType=%s(%s) recorded=%s reason=%s",
+ vehicleName,
+ tostring(vehicleId),
+ tostring(fieldID),
+ tostring(fillUnitIndex),
+ tonumber(oldLevel or 0) or 0,
+ tonumber(newLevel or 0) or 0,
+ amount,
+ tostring(oldFillType),
+ tostring(newFillType),
+ tostring(cutterInputFillType),
+ tostring(cutterOutputFillType),
+ fillTypeTitle,
+ tostring(activeFillType),
+ tostring(recorded),
+ reason
+ ))
+ else
+ local count = self.combineFillLevelDebugCallCount[vehicleId] or 0
+ if count <= self.combineFillLevelDebugMaxIdleLogsPerVehicle or count % 300 == 0 then
+ self:debugHarvestMessage(string.format(
+ "COMBINE_DELTA_IDLE vehicle='%s' vehicleId=%s field=%s fillUnit=%s old=%.2f new=%.2f delta=%.2f newFillType=%s activeFillType=%s(%s) call=%d",
+ vehicleName,
+ tostring(vehicleId),
+ tostring(fieldID),
+ tostring(fillUnitIndex),
+ tonumber(oldLevel or 0) or 0,
+ tonumber(newLevel or 0) or 0,
+ amount,
+ tostring(newFillType),
+ self:getFillTypeDebugTitle(activeFillType),
+ tostring(activeFillType),
+ count
+ ))
end
+ end
+end
- v = v + 1
+function YieldTracker:getFillTypeDebugTitle(fillTypeIndex)
+ if fillTypeIndex == nil then
+ return "nil"
end
- delete(xmlFile)
+ if g_fillTypeManager ~= nil and g_fillTypeManager.indexToFillType ~= nil then
+ local desc = g_fillTypeManager.indexToFillType[fillTypeIndex]
+ if desc ~= nil then
+ return tostring(desc.title or desc.name or fillTypeIndex)
+ end
+ end
- self.yieldDataLoaded = true
- self:debugMessage("Yield data loaded from XML")
+ return tostring(fillTypeIndex)
end
--- Saves yield tracking data and vehicle baseline fill levels to an XML file in the current savegame directory.
--- Data saved includes harvested amounts grouped by year, field, and crop, as well as last known fill levels per vehicle.
-function YieldTracker:saveYieldData()
- if not g_currentMission or not g_currentMission.missionInfo.savegameDirectory then
- self:debugMessage("Save skipped: no savegame directory")
+-- REDUCED PIPE TRACKING HOOK
+-- Tracks only the most likely pipe transfer path: Dischargeable.onUpdateTick.
+-- Debug output is restricted to harvest vehicles only.
+-- Pipe-only comparison formula:
+-- pipeHarvestAmount = targetDelta
+-- targetDelta = liters added to the overload/trailer target during this discharge tick
+-- Tank drawdown is NOT subtracted anymore. For the pipe-only approach, every liter that
+-- leaves the combine/harvester pipe is relevant.
+function YieldTracker:installPipeDischargeTrackingHook()
+ self:debugHarvestMessage("installPipeDischargeTrackingHook() called")
+
+ if self.pipeDischargeTrackingHookInstalled then
+ self:debugHarvestMessage("installPipeDischargeTrackingHook() skipped: already installed")
return
end
- local filePath = g_currentMission.missionInfo.savegameDirectory .. "/" .. self.saveFileName
- local xmlFile = createXMLFile("YieldDataXML", filePath, "yieldData")
+ self.pipeDischargeCallCount = self.pipeDischargeCallCount or {}
+ self.pipeDischargeMaxIdleLogsPerVehicle = 20
- -- Save yield data per year/field/crop
- local i = 0
- for year, fields in pairs(self.yieldData) do
- local yearKey = string.format("yieldData.year(%d)", i)
- setXMLInt(xmlFile, yearKey .. "#value", year)
+ self:debugHarvestMessage("PIPE_ONLY_DEBUG_V2_EXISTS Dischargeable=" .. tostring(Dischargeable ~= nil))
+ self:debugHarvestMessage("PIPE_ONLY_DEBUG_V2_EXISTS Dischargeable.onUpdateTick=" .. tostring(Dischargeable ~= nil and type(Dischargeable.onUpdateTick) == "function"))
+
+ if Dischargeable == nil or type(Dischargeable.onUpdateTick) ~= "function" then
+ self:debugHarvestMessage("ERROR: Dischargeable.onUpdateTick not found; pipe tracking hook not installed")
+ return
+ end
+
+ local tracker = self
+
+ Dischargeable.onUpdateTick = Utils.overwrittenFunction(Dischargeable.onUpdateTick, function(vehicle, superFunc, dt, ...)
+ local args = {...}
+ local beforeCombineLevel, beforeCombineFillType, combineFillUnitIndex = tracker:getCombineInternalFillState(vehicle)
+ local beforeTargetObject, beforeTargetFillUnitIndex, beforeTargetLevel, beforeTargetFillType = tracker:getPipeTargetState(vehicle, args)
+
+ local result = superFunc(vehicle, dt, ...)
+
+ local afterCombineLevel, afterCombineFillType = tracker:getCombineInternalFillState(vehicle)
+ local afterTargetObject, afterTargetFillUnitIndex, afterTargetLevel, afterTargetFillType = tracker:getPipeTargetState(vehicle, args, beforeTargetObject, beforeTargetFillUnitIndex)
+
+ tracker:handlePipeDischargeTracking(
+ vehicle,
+ dt,
+ beforeTargetObject,
+ beforeTargetFillUnitIndex,
+ beforeTargetLevel,
+ beforeTargetFillType,
+ afterTargetObject,
+ afterTargetFillUnitIndex,
+ afterTargetLevel,
+ afterTargetFillType,
+ combineFillUnitIndex,
+ beforeCombineLevel,
+ beforeCombineFillType,
+ afterCombineLevel,
+ afterCombineFillType
+ )
+
+ return result
+ end)
+
+ self.pipeDischargeTrackingHookInstalled = true
+ self:debugHarvestMessage("PIPE_ONLY_DEBUG_V2_INSTALLED Dischargeable.onUpdateTick")
+ self:debugHarvestMessage("installPipeDischargeTrackingHook() finished")
+end
+
+function YieldTracker:getPipeTargetState(vehicle, args, knownTargetObject, knownTargetFillUnitIndex)
+ if knownTargetObject ~= nil and knownTargetFillUnitIndex ~= nil and knownTargetObject.getFillUnitFillLevel ~= nil then
+ local level = knownTargetObject:getFillUnitFillLevel(knownTargetFillUnitIndex)
+ local fillType = nil
+ if knownTargetObject.getFillUnitFillType ~= nil then
+ fillType = knownTargetObject:getFillUnitFillType(knownTargetFillUnitIndex)
+ end
+ return knownTargetObject, knownTargetFillUnitIndex, level, fillType
+ end
+
+ if vehicle == nil or vehicle.getDischargeTargetObject == nil then
+ return nil, nil, nil, nil
+ end
+
+ if args ~= nil then
+ for _, possibleDischargeNode in ipairs(args) do
+ local ok, targetObject, targetFillUnitIndex = pcall(function()
+ return vehicle:getDischargeTargetObject(possibleDischargeNode)
+ end)
+
+ if ok and targetObject ~= nil and targetFillUnitIndex ~= nil then
+ local level = nil
+ local fillType = nil
+ if targetObject.getFillUnitFillLevel ~= nil then
+ level = targetObject:getFillUnitFillLevel(targetFillUnitIndex)
+ end
+ if targetObject.getFillUnitFillType ~= nil then
+ fillType = targetObject:getFillUnitFillType(targetFillUnitIndex)
+ end
+ return targetObject, targetFillUnitIndex, level, fillType
+ end
+ end
+ end
+
+ local dischargeSpec = vehicle.spec_dischargeable or (vehicle.spec_combine ~= nil and vehicle.spec_combine.spec_dischargeable) or nil
+ if dischargeSpec ~= nil and dischargeSpec.dischargeNodes ~= nil then
+ for _, dischargeNode in pairs(dischargeSpec.dischargeNodes) do
+ local ok, targetObject, targetFillUnitIndex = pcall(function()
+ return vehicle:getDischargeTargetObject(dischargeNode)
+ end)
+
+ if ok and targetObject ~= nil and targetFillUnitIndex ~= nil then
+ local level = nil
+ local fillType = nil
+ if targetObject.getFillUnitFillLevel ~= nil then
+ level = targetObject:getFillUnitFillLevel(targetFillUnitIndex)
+ end
+ if targetObject.getFillUnitFillType ~= nil then
+ fillType = targetObject:getFillUnitFillType(targetFillUnitIndex)
+ end
+ return targetObject, targetFillUnitIndex, level, fillType
+ end
+ end
+ end
+
+ return nil, nil, nil, nil
+end
+
+
+function YieldTracker:isPipeDebugHarvestVehicle(vehicle)
+ if vehicle == nil then
+ return false
+ end
+
+ -- Normal combine harvesters and many specialized harvesters use spec_combine.
+ if vehicle.spec_combine ~= nil then
+ return true
+ end
+
+ -- Fallback for modded harvesters that expose a harvester specialization/name instead of spec_combine.
+ if vehicle.spec_harvester ~= nil or vehicle.spec_combineDrivable ~= nil then
+ return true
+ end
+
+ local typeName = nil
+ if vehicle.typeName ~= nil then
+ typeName = tostring(vehicle.typeName):lower()
+ elseif vehicle.configFileName ~= nil then
+ typeName = tostring(vehicle.configFileName):lower()
+ end
+
+ if typeName ~= nil then
+ if typeName:find("combine", 1, true) ~= nil or typeName:find("harvester", 1, true) ~= nil then
+ return true
+ end
+ end
+
+ return false
+end
+
+function YieldTracker:handlePipeDischargeTracking(vehicle, dt, beforeTargetObject, beforeTargetFillUnitIndex, beforeTargetLevel, beforeTargetFillType, afterTargetObject, afterTargetFillUnitIndex, afterTargetLevel, afterTargetFillType, combineFillUnitIndex, beforeCombineLevel, beforeCombineFillType, afterCombineLevel, afterCombineFillType)
+ if vehicle == nil then
+ return
+ end
+
+ if not self:isPipeDebugHarvestVehicle(vehicle) then
+ return
+ end
+
+ if not self.yieldDataLoaded then
+ return
+ end
+
+ local targetDelta = 0
+ if beforeTargetLevel ~= nil and afterTargetLevel ~= nil then
+ targetDelta = (tonumber(afterTargetLevel) or 0) - (tonumber(beforeTargetLevel) or 0)
+ end
+
+ if targetDelta <= 0.001 then
+ return
+ end
+
+ local fieldID = self:getCutterFieldID(vehicle)
+ if fieldID == nil then
+ return
+ end
+
+ local cutterInputFillType, cutterOutputFillType = self:getCutterStatus(vehicle)
+ local activeFillType = self:getActiveFillType(afterCombineFillType, afterTargetFillType, cutterOutputFillType)
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = self:getActiveFillType(cutterOutputFillType, cutterInputFillType, beforeCombineFillType)
+ end
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = self:getFieldFillTypeFallback(vehicle)
+ end
+
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN or not self:isTrackedFillType(activeFillType) then
+ self:debugForageHarvesterPipeTransfer(vehicle, fieldID, afterTargetObject, afterTargetFillUnitIndex, targetDelta, afterTargetFillType, activeFillType, false)
+ return
+ end
+
+ local currentYear = g_currentMission.environment.currentYear
+ self:addPipeHarvestAmount(currentYear, fieldID, activeFillType, targetDelta)
+ self:debugForageHarvesterPipeTransfer(vehicle, fieldID, afterTargetObject, afterTargetFillUnitIndex, targetDelta, afterTargetFillType, activeFillType, true)
+
+ -- After a successful pipe transfer, the combine tank usually changed too.
+ -- Refresh the transient tank contribution so the GUI shows pipe + remaining tank.
+ self:updateCombineLiveTank(vehicle, fieldID, activeFillType)
+end
+
+
+
+-- PIPE-BASED FINAL HARVEST CALCULATION
+--=======================================================================================================================================================================
+function YieldTracker:makeYieldKey(year, fieldID, fillTypeIndex)
+ if year == nil or fieldID == nil or fillTypeIndex == nil then
+ return nil
+ end
+ return tostring(year) .. ":" .. tostring(fieldID) .. ":" .. tostring(fillTypeIndex)
+end
+
+function YieldTracker:getFillTypeTitle(fillTypeIndex)
+ local fillTypeDesc = g_fillTypeManager ~= nil and g_fillTypeManager.indexToFillType ~= nil and g_fillTypeManager.indexToFillType[fillTypeIndex] or nil
+ if fillTypeDesc ~= nil then
+ return fillTypeDesc.title or fillTypeDesc.name or tostring(fillTypeIndex)
+ end
+ return tostring(fillTypeIndex)
+end
+
+function YieldTracker:initializePipeBaseFromYieldData()
+ self.pipeBaseTotals = {}
+ self.liveTankContributions = {}
+ self.vehicleLiveTankKeys = {}
+
+ local liveByKey = {}
+ if self.savedLiveTankContributions ~= nil then
+ for _, entry in pairs(self.savedLiveTankContributions) do
+ local key = self:makeYieldKey(entry.year, entry.fieldID, entry.fillTypeIndex)
+ if key ~= nil then
+ liveByKey[key] = (liveByKey[key] or 0) + (tonumber(entry.amount) or 0)
+ end
+ end
+ end
+
+ for year, fields in pairs(self.yieldData or {}) do
+ self.pipeBaseTotals[year] = self.pipeBaseTotals[year] or {}
+ for fieldID, crops in pairs(fields) do
+ self.pipeBaseTotals[year][fieldID] = self.pipeBaseTotals[year][fieldID] or {}
+ for fillTypeIndex, cropData in pairs(crops) do
+ local amount = tonumber(cropData.amount or 0) or 0
+ local key = self:makeYieldKey(year, fieldID, fillTypeIndex)
+ local liveAmount = liveByKey[key] or 0
+ self.pipeBaseTotals[year][fieldID][fillTypeIndex] = math.max(0, amount - liveAmount)
+ end
+ end
+ end
+
+ self.savedLiveTankContributions = nil
+end
+
+function YieldTracker:getPipeBaseAmount(year, fieldID, fillTypeIndex)
+ if self.pipeBaseTotals == nil or self.pipeBaseTotals[year] == nil or self.pipeBaseTotals[year][fieldID] == nil then
+ return 0
+ end
+ return tonumber(self.pipeBaseTotals[year][fieldID][fillTypeIndex] or 0) or 0
+end
+
+function YieldTracker:getLiveTankAmountForKey(key)
+ local total = 0
+ for _, contribution in pairs(self.liveTankContributions or {}) do
+ if contribution.key == key then
+ total = total + (tonumber(contribution.amount) or 0)
+ end
+ end
+ return total
+end
+
+function YieldTracker:syncYieldDataEntry(year, fieldID, fillTypeIndex)
+ if year == nil or fieldID == nil or fillTypeIndex == nil then
+ return
+ end
+
+ self.yieldData[year] = self.yieldData[year] or {}
+ self.yieldData[year][fieldID] = self.yieldData[year][fieldID] or {}
+
+ local key = self:makeYieldKey(year, fieldID, fillTypeIndex)
+ local amount = self:getPipeBaseAmount(year, fieldID, fillTypeIndex) + self:getLiveTankAmountForKey(key)
+
+ self.yieldData[year][fieldID][fillTypeIndex] = self.yieldData[year][fieldID][fillTypeIndex] or {
+ amount = 0,
+ fillTypeTitle = self:getFillTypeTitle(fillTypeIndex)
+ }
+
+ self.yieldData[year][fieldID][fillTypeIndex].amount = amount
+ self.yieldData[year][fieldID][fillTypeIndex].fillTypeTitle = self:getFillTypeTitle(fillTypeIndex)
+end
+
+function YieldTracker:addPipeHarvestAmount(year, fieldID, fillTypeIndex, amount)
+ amount = tonumber(amount or 0) or 0
+ if year == nil or fieldID == nil or fillTypeIndex == nil or amount <= 0.001 then
+ return
+ end
+
+ self.pipeBaseTotals = self.pipeBaseTotals or {}
+ self.pipeBaseTotals[year] = self.pipeBaseTotals[year] or {}
+ self.pipeBaseTotals[year][fieldID] = self.pipeBaseTotals[year][fieldID] or {}
+ self.pipeBaseTotals[year][fieldID][fillTypeIndex] = (self.pipeBaseTotals[year][fieldID][fillTypeIndex] or 0) + amount
+
+ self:syncYieldDataEntry(year, fieldID, fillTypeIndex)
+end
+
+function YieldTracker:updateCombineLiveTank(vehicle, fieldID, fillTypeIndex)
+ if vehicle == nil or not self.yieldDataLoaded then
+ return
+ end
+
+ local year = g_currentMission.environment.currentYear
+ local vehicleId = vehicle.id or tostring(vehicle)
+ local oldContribution = self.liveTankContributions ~= nil and self.liveTankContributions[vehicleId] or nil
+
+ if oldContribution ~= nil then
+ self.liveTankContributions[vehicleId] = nil
+ self:syncYieldDataEntry(oldContribution.year, oldContribution.fieldID, oldContribution.fillTypeIndex)
+ end
+
+ local tankLevel = 0
+ local tankFillType = fillTypeIndex
+ local fillLevel, currentFillType = self:getCombineInternalFillState(vehicle)
+ if fillLevel ~= nil then
+ tankLevel = tonumber(fillLevel or 0) or 0
+ end
+ if currentFillType ~= nil and currentFillType ~= FillType.UNKNOWN then
+ tankFillType = currentFillType
+ end
+
+ if tankLevel <= 0.001 then
+ return
+ end
+
+ if fieldID == nil or fillTypeIndex == nil or fillTypeIndex == FillType.UNKNOWN then
+ return
+ end
+
+ local key = self:makeYieldKey(year, fieldID, fillTypeIndex)
+ self.liveTankContributions = self.liveTankContributions or {}
+ self.liveTankContributions[vehicleId] = {
+ key = key,
+ year = year,
+ fieldID = fieldID,
+ fillTypeIndex = fillTypeIndex,
+ amount = tankLevel
+ }
+
+ self:syncYieldDataEntry(year, fieldID, fillTypeIndex)
+end
+
+function YieldTracker:installCombineLiveTankTrackingHook()
+ if self.combineLiveTankTrackingHookInstalled then
+ return
+ end
+
+ if Combine == nil or type(Combine.onUpdateTick) ~= "function" then
+ return
+ end
+
+ local tracker = self
+ Combine.onUpdateTick = Utils.overwrittenFunction(Combine.onUpdateTick, function(vehicle, superFunc, dt, ...)
+ local result = superFunc(vehicle, dt, ...)
+
+ if tracker:isPipeDebugHarvestVehicle(vehicle) then
+ local fieldID = tracker:getCutterFieldID(vehicle)
+ local currentLevel, currentFillType = tracker:getCombineInternalFillState(vehicle)
+ local cutterInputFillType, cutterOutputFillType = tracker:getCutterStatus(vehicle)
+ local activeFillType = tracker:getActiveFillType(currentFillType, cutterOutputFillType, cutterInputFillType)
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = tracker:getFieldFillTypeFallback(vehicle)
+ end
+
+ if fieldID ~= nil and activeFillType ~= nil and activeFillType ~= FillType.UNKNOWN and tracker:isTrackedFillType(activeFillType) then
+ tracker:updateCombineLiveTank(vehicle, fieldID, activeFillType)
+ elseif currentLevel ~= nil and currentLevel <= 0.001 then
+ tracker:updateCombineLiveTank(vehicle, nil, nil)
+ end
+ end
+
+ return result
+ end)
+
+ self.combineLiveTankTrackingHookInstalled = true
+end
+
+-- PIPE-ONLY DEBUG CALCULATION
+--=======================================================================================================================================================================
+function YieldTracker:addPipeOnlyDebugAmount(year, fieldID, fillTypeIndex, amount)
+ if year == nil or fieldID == nil or fillTypeIndex == nil then
+ return
+ end
+
+ amount = tonumber(amount or 0) or 0
+ if amount <= 0.001 then
+ return
+ end
+
+ self.pipeOnlyDebugTotals = self.pipeOnlyDebugTotals or {}
+ self.pipeOnlyDebugTotals[year] = self.pipeOnlyDebugTotals[year] or {}
+ self.pipeOnlyDebugTotals[year][fieldID] = self.pipeOnlyDebugTotals[year][fieldID] or {}
+ self.pipeOnlyDebugTotals[year][fieldID][fillTypeIndex] = (self.pipeOnlyDebugTotals[year][fieldID][fillTypeIndex] or 0) + amount
+end
+
+function YieldTracker:getPipeOnlyDebugAmount(year, fieldID, fillTypeIndex)
+ if self.pipeOnlyDebugTotals == nil then
+ return 0
+ end
+
+ if self.pipeOnlyDebugTotals[year] == nil or self.pipeOnlyDebugTotals[year][fieldID] == nil then
+ return 0
+ end
+
+ return self.pipeOnlyDebugTotals[year][fieldID][fillTypeIndex] or 0
+end
+
+function YieldTracker:getCurrentCombineTankForDebug(vehicle, expectedFillType)
+ local level, fillType, fillUnitIndex = self:getCombineInternalFillState(vehicle)
+
+ if expectedFillType ~= nil and fillType ~= nil and fillType ~= expectedFillType then
+ return 0, fillType, fillUnitIndex
+ end
+
+ return tonumber(level or 0) or 0, fillType, fillUnitIndex
+end
+
+function YieldTracker:formatDebugNumber(value)
+ if value == nil then
+ return "nil"
+ end
+ local numberValue = tonumber(value)
+ if numberValue == nil then
+ return tostring(value)
+ end
+ return string.format("%.2f", numberValue)
+end
+
+
+-- LOOSE MATERIAL PRODUCTIVE TRACKING + FORAGE HARVESTER PIPE DEBUG
+--=======================================================================================================================================================================
+-- Productive tracking for loose material pickup:
+-- - ForageWagon.onUpdateTick
+-- - Baler.onUpdateTick
+--
+-- These write directly into yieldData via addPipeHarvestAmount(), so the GUI sees the values.
+-- Debug remains limited to forage harvester pipe transfers and is printed only when a positive target delta exists.
+
+function YieldTracker:isLooseMaterialProductiveVehicle(vehicle)
+ if vehicle == nil or type(vehicle) ~= "table" then
+ return false
+ end
+
+ if vehicle.spec_baler ~= nil then return true end
+ if vehicle.spec_forageWagon ~= nil then return true end
+
+ local typeName = nil
+ if vehicle.typeName ~= nil then
+ typeName = tostring(vehicle.typeName):lower()
+ elseif vehicle.configFileName ~= nil then
+ typeName = tostring(vehicle.configFileName):lower()
+ end
+
+ if typeName ~= nil then
+ if typeName:find("baler", 1, true) ~= nil then return true end
+ if typeName:find("foragewagon", 1, true) ~= nil then return true end
+ if typeName:find("forage_wagon", 1, true) ~= nil then return true end
+ if typeName:find("mowerforagewagon", 1, true) ~= nil then return true end
+ end
+
+ return false
+end
+
+function YieldTracker:isForageHarvesterDebugVehicle(vehicle)
+ if vehicle == nil or type(vehicle) ~= "table" then
+ return false
+ end
+
+ if vehicle.spec_combine ~= nil then
+ local typeName = tostring(vehicle.typeName or vehicle.configFileName or ""):lower()
+ if typeName:find("forage", 1, true) ~= nil or typeName:find("chopper", 1, true) ~= nil then
+ return true
+ end
+ end
+
+ if vehicle.spec_harvester ~= nil then
+ local typeName = tostring(vehicle.typeName or vehicle.configFileName or ""):lower()
+ if typeName:find("forage", 1, true) ~= nil or typeName:find("chopper", 1, true) ~= nil or typeName:find("bigx", 1, true) ~= nil then
+ return true
+ end
+ end
+
+ local typeName = nil
+ if vehicle.typeName ~= nil then
+ typeName = tostring(vehicle.typeName):lower()
+ elseif vehicle.configFileName ~= nil then
+ typeName = tostring(vehicle.configFileName):lower()
+ end
+
+ if typeName ~= nil then
+ if typeName:find("forageharvester", 1, true) ~= nil then return true end
+ if typeName:find("forage_harvester", 1, true) ~= nil then return true end
+ if typeName:find("woodharvester", 1, true) == nil and typeName:find("harvester", 1, true) ~= nil and typeName:find("forage", 1, true) ~= nil then return true end
+ if typeName:find("bigx", 1, true) ~= nil then return true end
+ if typeName:find("jaguar", 1, true) ~= nil then return true end
+ if typeName:find("fr780", 1, true) ~= nil then return true end
+ if typeName:find("fh", 1, true) ~= nil and typeName:find("combine", 1, true) == nil then return true end
+ end
+
+ return false
+end
+
+function YieldTracker:getLoosePickupFieldID(vehicle)
+ if vehicle == nil or type(vehicle) ~= "table" then
+ return nil
+ end
+
+ if vehicle.rootNode ~= nil and g_farmlandManager ~= nil and g_farmlandManager.getFarmlandIdAtWorldPosition ~= nil then
+ local x, _, z = getWorldTranslation(vehicle.rootNode)
+ return g_farmlandManager:getFarmlandIdAtWorldPosition(x, z)
+ end
+
+ return nil
+end
+
+function YieldTracker:getLoosePickupSnapshot(vehicle)
+ local snapshot = {
+ totalFillLevel = 0,
+ fillUnitCount = 0,
+ fillUnitLevels = {},
+ fillUnitFillTypes = {},
+ changedFillUnitIndex = nil,
+ changedFillTypeIndex = nil,
+ changedFillLevel = nil
+ }
+
+ if vehicle == nil or type(vehicle) ~= "table" then
+ return snapshot
+ end
+
+ local count = 0
+ if vehicle.spec_fillUnit ~= nil and vehicle.spec_fillUnit.fillUnits ~= nil then
+ count = #vehicle.spec_fillUnit.fillUnits
+ elseif vehicle.getFillUnits ~= nil then
+ local ok, fillUnits = pcall(function() return vehicle:getFillUnits() end)
+ if ok and type(fillUnits) == "table" then
+ count = #fillUnits
+ end
+ end
+
+ snapshot.fillUnitCount = count
+
+ if vehicle.getFillUnitFillLevel ~= nil then
+ for i = 1, count do
+ local level = vehicle:getFillUnitFillLevel(i) or 0
+ local fillTypeIndex = nil
+
+ if vehicle.getFillUnitFillType ~= nil then
+ fillTypeIndex = vehicle:getFillUnitFillType(i)
+ elseif vehicle.spec_fillUnit ~= nil and vehicle.spec_fillUnit.fillUnits ~= nil and vehicle.spec_fillUnit.fillUnits[i] ~= nil then
+ fillTypeIndex = vehicle.spec_fillUnit.fillUnits[i].fillType
+ end
+
+ snapshot.fillUnitLevels[i] = level
+ snapshot.fillUnitFillTypes[i] = fillTypeIndex
+ snapshot.totalFillLevel = snapshot.totalFillLevel + level
+
+ if snapshot.changedFillUnitIndex == nil or level > (snapshot.changedFillLevel or -1) then
+ snapshot.changedFillUnitIndex = i
+ snapshot.changedFillTypeIndex = fillTypeIndex
+ snapshot.changedFillLevel = level
+ end
+ end
+ end
+
+ return snapshot
+end
+
+function YieldTracker:getLoosePickupPositiveDelta(before, after)
+ if before == nil or after == nil then
+ return 0
+ end
+
+ local delta = (after.totalFillLevel or 0) - (before.totalFillLevel or 0)
+ if delta > 0.01 then
+ return delta
+ end
+
+ return 0
+end
+
+
+function YieldTracker:getFillTypeIndexByNameSafe(fillTypeName)
+ if fillTypeName == nil or g_fillTypeManager == nil then
+ return nil
+ end
+
+ if g_fillTypeManager.getFillTypeIndexByName ~= nil then
+ local ok, result = pcall(function() return g_fillTypeManager:getFillTypeIndexByName(fillTypeName) end)
+ if ok and result ~= nil and result ~= FillType.UNKNOWN then
+ return result
+ end
+ end
+
+ if g_fillTypeManager.nameToIndex ~= nil then
+ local result = g_fillTypeManager.nameToIndex[fillTypeName]
+ if result ~= nil and result ~= FillType.UNKNOWN then
+ return result
+ end
+ end
+
+ if g_fillTypeManager.nameToFillType ~= nil and g_fillTypeManager.nameToFillType[fillTypeName] ~= nil then
+ local desc = g_fillTypeManager.nameToFillType[fillTypeName]
+ local result = desc.index or desc.fillTypeIndex
+ if result ~= nil and result ~= FillType.UNKNOWN then
+ return result
+ end
+ end
+
+ if g_fillTypeManager.indexToFillType ~= nil then
+ for index, desc in pairs(g_fillTypeManager.indexToFillType) do
+ if desc ~= nil and desc.name == fillTypeName then
+ return index
+ end
+ end
+ end
+
+ return nil
+end
+
+function YieldTracker:getNormalizedLoosePickupFillType(fillTypeIndex)
+ if fillTypeIndex == nil or fillTypeIndex == FillType.UNKNOWN then
+ return fillTypeIndex
+ end
+
+ local fillTypeName = nil
+ if g_fillTypeManager ~= nil then
+ if g_fillTypeManager.getFillTypeNameByIndex ~= nil then
+ local ok, result = pcall(function() return g_fillTypeManager:getFillTypeNameByIndex(fillTypeIndex) end)
+ if ok and result ~= nil then
+ fillTypeName = tostring(result)
+ end
+ end
+
+ if fillTypeName == nil and g_fillTypeManager.indexToFillType ~= nil and g_fillTypeManager.indexToFillType[fillTypeIndex] ~= nil then
+ fillTypeName = g_fillTypeManager.indexToFillType[fillTypeIndex].name
+ end
+ end
+
+ if fillTypeName == nil then
+ return fillTypeIndex
+ end
+
+ local normalizedName = fillTypeName
+
+ if fillTypeName == "GRASS_WINDROW" then
+ normalizedName = "GRASS"
+ elseif fillTypeName == "DRYGRASS_WINDROW" then
+ -- Keep hay as DRYGRASS_WINDROW. This is the fill type that is actually collected
+ -- and it can be displayed as localized "Heu" in the GUI.
+ normalizedName = "DRYGRASS_WINDROW"
+ elseif fillTypeName == "STRAW" or fillTypeName == "STRAW_WINDROW" then
+ normalizedName = "STRAW"
+ elseif fillTypeName == "CHAFF_WINDROW" then
+ normalizedName = "CHAFF"
+ elseif string.sub(fillTypeName, -8) == "_WINDROW" then
+ -- Do not blindly strip every windrow suffix: some windrow fill types, e.g. hay,
+ -- are the selectable/storable fill type and should remain unchanged.
+ normalizedName = fillTypeName
+ end
+
+ if normalizedName ~= fillTypeName then
+ local normalizedIndex = self:getFillTypeIndexByNameSafe(normalizedName)
+ if normalizedIndex ~= nil and normalizedIndex ~= FillType.UNKNOWN then
+ return normalizedIndex
+ end
+ end
+
+ return fillTypeIndex
+end
+
+function YieldTracker:trackLooseMaterialPickupUpdate(hookName, vehicle, dt)
+ if not self.yieldDataLoaded then
+ return
+ end
+
+ if not self:isLooseMaterialProductiveVehicle(vehicle) then
+ return
+ end
+
+ self.loosePickupBaselines = self.loosePickupBaselines or {}
+
+ local vehicleId = vehicle.id or vehicle.rootNode or tostring(vehicle)
+ local snapshot = self:getLoosePickupSnapshot(vehicle)
+ local previous = self.loosePickupBaselines[vehicleId]
+
+ self.loosePickupBaselines[vehicleId] = snapshot
+
+ if previous == nil then
+ return
+ end
+
+ local amount = self:getLoosePickupPositiveDelta(previous, snapshot)
+ if amount <= 0.01 then
+ return
+ end
+
+ local fieldID = self:getLoosePickupFieldID(vehicle)
+ if fieldID == nil then
+ return
+ end
+
+ local fillTypeIndex = snapshot.changedFillTypeIndex
+ if (fillTypeIndex == nil or fillTypeIndex == FillType.UNKNOWN) and snapshot.changedFillUnitIndex ~= nil and vehicle.getFillUnitFillType ~= nil then
+ fillTypeIndex = vehicle:getFillUnitFillType(snapshot.changedFillUnitIndex)
+ end
+
+ if fillTypeIndex == nil or fillTypeIndex == FillType.UNKNOWN then
+ return
+ end
+
+ fillTypeIndex = self:getNormalizedLoosePickupFillType(fillTypeIndex)
+
+ local year = g_currentMission.environment.currentYear
+ self:addPipeHarvestAmount(year, fieldID, fillTypeIndex, amount)
+end
+
+function YieldTracker:installLooseMaterialPickupTrackingHook()
+ if self.looseMaterialPickupTrackingHookInstalled then
+ return
+ end
+
+ local tracker = self
+ local installedBaler = false
+ local installedForageWagon = false
+
+ if Baler ~= nil and type(Baler.onUpdateTick) == "function" and not self.loosePickupBalerHookInstalled then
+ Baler.onUpdateTick = Utils.appendedFunction(Baler.onUpdateTick, function(vehicle, dt, isActiveForInput, isActiveForInputIgnoreSelection, isSelected, ...)
+ tracker:trackLooseMaterialPickupUpdate("Baler.onUpdateTick", vehicle, dt)
+ end)
+ self.loosePickupBalerHookInstalled = true
+ installedBaler = true
+ end
+
+ if ForageWagon ~= nil and type(ForageWagon.onUpdateTick) == "function" and not self.loosePickupForageWagonHookInstalled then
+ ForageWagon.onUpdateTick = Utils.appendedFunction(ForageWagon.onUpdateTick, function(vehicle, dt, isActiveForInput, isActiveForInputIgnoreSelection, isSelected, ...)
+ tracker:trackLooseMaterialPickupUpdate("ForageWagon.onUpdateTick", vehicle, dt)
+ end)
+ self.loosePickupForageWagonHookInstalled = true
+ installedForageWagon = true
+ end
+
+ self.looseMaterialPickupTrackingHookInstalled = true
+
+ if self.debugHarvest then
+ print(string.format("%sLOOSE_PICKUP_TRACKING_INSTALLED baler=%s forageWagon=%s", PRINT_PREFIX, tostring(installedBaler), tostring(installedForageWagon)))
+ end
+end
+
+function YieldTracker:getFillTypeDebugName(fillTypeIndex)
+ if fillTypeIndex == nil then
+ return "nil"
+ end
+
+ if g_fillTypeManager ~= nil then
+ if g_fillTypeManager.getFillTypeNameByIndex ~= nil then
+ local ok, result = pcall(function() return g_fillTypeManager:getFillTypeNameByIndex(fillTypeIndex) end)
+ if ok and result ~= nil then return tostring(result) end
+ end
+ if g_fillTypeManager.getFillTypeTitleByIndex ~= nil then
+ local ok, result = pcall(function() return g_fillTypeManager:getFillTypeTitleByIndex(fillTypeIndex) end)
+ if ok and result ~= nil then return tostring(result) end
+ end
+ if g_fillTypeManager.indexToFillType ~= nil and g_fillTypeManager.indexToFillType[fillTypeIndex] ~= nil then
+ local desc = g_fillTypeManager.indexToFillType[fillTypeIndex]
+ return tostring(desc.name or desc.title or fillTypeIndex)
+ end
+ end
+
+ return tostring(fillTypeIndex)
+end
+
+function YieldTracker:debugForageHarvesterPipeTransfer(vehicle, fieldID, targetObject, targetFillUnitIndex, targetDelta, targetFillType, activeFillType, recorded)
+ if not self:isForageHarvesterDebugVehicle(vehicle) then
+ return
+ end
+
+ local targetName = "nil"
+ if targetObject ~= nil then
+ targetName = self:getVehicleDebugName(targetObject)
+ end
+
+ print(string.format(
+ "%sFORAGE_HARVESTER_PIPE_DEBUG vehicle='%s' vehicleId=%s typeName=%s field=%s target='%s' targetFillUnit=%s targetDelta=%.2f targetFillType=%s(%s) activeFillType=%s(%s) recordedToGui=%s",
+ PRINT_PREFIX,
+ self:getVehicleDebugName(vehicle),
+ tostring(vehicle ~= nil and vehicle.id or nil),
+ tostring(vehicle ~= nil and vehicle.typeName or nil),
+ tostring(fieldID),
+ tostring(targetName),
+ tostring(targetFillUnitIndex),
+ tonumber(targetDelta) or 0,
+ self:getFillTypeDebugName(targetFillType),
+ tostring(targetFillType),
+ self:getFillTypeDebugName(activeFillType),
+ tostring(activeFillType),
+ tostring(recorded)
+ ))
+end
+
+
+-- SAVE/LOAD METHODS
+--=======================================================================================================================================================================
+
+-- Call required dependencies upon loadmap from main.lua
+function YieldTracker:setup()
+ self:loadYieldData()
+ self:initializePipeBaseFromYieldData()
+ self:installCombineLiveTankTrackingHook()
+ self:installPipeDischargeTrackingHook()
+ self:installLooseMaterialPickupTrackingHook()
+ self:hookInGameSaveEvent()
+end
+
+
+-- Hooks into the save event to trigger yield data saving on every game save.
+function YieldTracker:hookInGameSaveEvent()
+ FSCareerMissionInfo.saveToXMLFile = Utils.appendedFunction(
+ FSCareerMissionInfo.saveToXMLFile,
+ function() YieldTracker:saveYieldData() end)
+end
+
+
+-- Loads yield tracking data and vehicle baseline fill levels from an XML file in the current savegame directory.
+-- Rebuilds the yieldData structure by year, field, and crop, and restores previous fill levels per vehicle.
+-- If no file exists or loading fails, initializes with empty data and marks data as loaded.
+function YieldTracker:loadYieldData()
+ if not g_currentMission or not g_currentMission.missionInfo.savegameDirectory then
+ self:debugMessage("Load skipped: no savegame directory")
+ self.yieldDataLoaded = true
+ return
+ end
+
+ local filePath = g_currentMission.missionInfo.savegameDirectory .. "/" .. self.saveFileName
+ if not fileExists(filePath) then
+ self:debugMessage("No existing yield data XML found")
+ self.yieldDataLoaded = true
+ return
+ end
+
+ local xmlFile = loadXMLFile("YieldDataXML", filePath)
+ if not xmlFile then
+ self:debugMessage("Failed to load yield data XML")
+ return
+ end
+
+ self.yieldData = {}
+
+ local i = 0
+ while true do
+ local yearKey = string.format("yieldData.year(%d)", i)
+ if not hasXMLProperty(xmlFile, yearKey) then break end
+
+ local year = getXMLInt(xmlFile, yearKey .. "#value")
+ self.yieldData[year] = {}
+
+ local j = 0
+ while true do
+ local fieldKey = string.format("%s.field(%d)", yearKey, j)
+ if not hasXMLProperty(xmlFile, fieldKey) then break end
+
+ local fieldId = getXMLInt(xmlFile, fieldKey .. "#id")
+ self.yieldData[year][fieldId] = {}
+
+ local k = 0
+ while true do
+ local cropKey = string.format("%s.crop(%d)", fieldKey, k)
+ if not hasXMLProperty(xmlFile, cropKey) then break end
+
+ local cropName = getXMLString(xmlFile, cropKey .. "#name")
+ local amount = getXMLFloat(xmlFile, cropKey .. "#amount")
+ local fillTypeIndex = getXMLInt(xmlFile, cropKey .. "#fillTypeIndex")
+
+ self.yieldData[year][fieldId][fillTypeIndex] = {
+ amount = amount,
+ fillTypeTitle = cropName
+ }
+
+ k = k + 1
+ end
+
+ j = j + 1
+ end
+
+ i = i + 1
+ end
+
+ -- Load prevFillLevels baseline per vehicle
+ self.prevFillLevels = {}
+ local v = 0
+ while true do
+ local vehicleKey = string.format("yieldData.prevFillLevels.vehicle(%d)", v)
+ if not hasXMLProperty(xmlFile, vehicleKey) then break end
+
+ local vehicleId = getXMLInt(xmlFile, vehicleKey .. "#id")
+ local level = getXMLFloat(xmlFile, vehicleKey .. "#level")
+
+ if vehicleId and level then
+ self.prevFillLevels[vehicleId] = level
+ end
+
+ v = v + 1
+ end
+
+
+ -- Load transient live tank contributions that were included in the saved display amount.
+ -- They are subtracted from pipeBaseTotals after loading to avoid double counting when the same tank is unloaded after reload.
+ self.savedLiveTankContributions = {}
+ local lt = 0
+ while true do
+ local liveKey = string.format("yieldData.liveTankContributions.vehicle(%d)", lt)
+ if not hasXMLProperty(xmlFile, liveKey) then break end
+
+ table.insert(self.savedLiveTankContributions, {
+ vehicleId = getXMLString(xmlFile, liveKey .. "#id"),
+ year = getXMLInt(xmlFile, liveKey .. "#year"),
+ fieldID = getXMLInt(xmlFile, liveKey .. "#fieldId"),
+ fillTypeIndex = getXMLInt(xmlFile, liveKey .. "#fillTypeIndex"),
+ amount = getXMLFloat(xmlFile, liveKey .. "#amount") or 0
+ })
+
+ lt = lt + 1
+ end
+
+ delete(xmlFile)
+
+ self.yieldDataLoaded = true
+ self:debugMessage("Yield data loaded from XML")
+end
+
+
+
+-- Saves yield tracking data and vehicle baseline fill levels to an XML file in the current savegame directory.
+-- Data saved includes harvested amounts grouped by year, field, and crop, as well as last known fill levels per vehicle.
+function YieldTracker:saveYieldData()
+ if not g_currentMission or not g_currentMission.missionInfo.savegameDirectory then
+ self:debugMessage("Save skipped: no savegame directory")
+ return
+ end
+
+ local filePath = g_currentMission.missionInfo.savegameDirectory .. "/" .. self.saveFileName
+ local xmlFile = createXMLFile("YieldDataXML", filePath, "yieldData")
+
+ -- Save yield data per year/field/crop
+ local i = 0
+ for year, fields in pairs(self.yieldData) do
+ local yearKey = string.format("yieldData.year(%d)", i)
+ setXMLInt(xmlFile, yearKey .. "#value", year)
local j = 0
for fieldId, crops in pairs(fields) do
@@ -448,10 +1764,26 @@ function YieldTracker:saveYieldData()
v = v + 1
end
+
+
+ -- Save transient live tank contributions separately so they can be subtracted from pipe base totals on reload.
+ local lt = 0
+ for vehicleId, contribution in pairs(self.liveTankContributions or {}) do
+ if contribution.amount ~= nil and contribution.amount > 0.001 then
+ local liveKey = string.format("yieldData.liveTankContributions.vehicle(%d)", lt)
+ setXMLString(xmlFile, liveKey .. "#id", tostring(vehicleId))
+ setXMLInt(xmlFile, liveKey .. "#year", contribution.year)
+ setXMLInt(xmlFile, liveKey .. "#fieldId", contribution.fieldID)
+ setXMLInt(xmlFile, liveKey .. "#fillTypeIndex", contribution.fillTypeIndex)
+ setXMLFloat(xmlFile, liveKey .. "#amount", contribution.amount)
+ lt = lt + 1
+ end
+ end
+
saveXMLFile(xmlFile)
delete(xmlFile)
self:debugMessage("Yield data saved to XML")
end
---=======================================================================================================================================================================
\ No newline at end of file
+--=======================================================================================================================================================================
diff --git a/FS25_YieldTracker/scripts/yieldTracker_backup.lua b/FS25_YieldTracker/scripts/yieldTracker_backup.lua
new file mode 100644
index 0000000..39323e1
--- /dev/null
+++ b/FS25_YieldTracker/scripts/yieldTracker_backup.lua
@@ -0,0 +1,1414 @@
+-- Author: BitBarn Mods
+-- Date: 06-11-2025
+-- Version: 1.0.0.0
+-- yieldTracker.lua
+
+
+PRINT_PREFIX = "[ YieldTracker ] - "
+
+YieldTracker = {
+ modDir = g_currentModDirectory,
+ yieldData = {},
+ saveFileName = "yieldTrackerData.xml",
+}
+YieldTracker.debug = false
+YieldTracker.debugHarvest = false
+
+
+--- Active crop fill types are resolved dynamically from g_fruitTypeManager.
+
+
+-- Called every frame to update yield tracking for all combines in the current mission.
+-- Iterates over all vehicles, checks for combines, determines active fill types, and tracks their fill units.
+function YieldTracker:updateByFrame()
+ -- Harvest amounts are recorded by Combine.onUpdateTick tank deltas and Dischargeable.onUpdateTick pipe deltas.
+ -- The old per-frame scan is intentionally disabled.
+end
+
+
+-- Called every frame to track fill unit changes for a given combine vehicle.
+function YieldTracker:trackFillUnit(vehicle, activeFillType, activeFillLevel, isbaler)
+ if not self.yieldDataLoaded then return end
+ if not vehicle or not activeFillType then return end
+
+ local currentYear = g_currentMission.environment.currentYear
+ local vehicleId = vehicle.id
+ local fieldID
+
+ if not isbaler then
+ fieldID = self:getCutterFieldID(vehicle)
+ elseif isbaler then
+ local baler_pos_x, baler_pos_y, baler_pos_z = getWorldTranslation(vehicle.rootNode)
+ fieldID = g_farmlandManager:getFarmlandIdAtWorldPosition(baler_pos_x, baler_pos_z)
+ end
+
+ if not fieldID then return end
+
+ local fillTypeDesc = g_fillTypeManager.indexToFillType[activeFillType]
+ if not fillTypeDesc then return end
+
+ local cropName = fillTypeDesc.title
+
+ self.prevFillLevels = self.prevFillLevels or {}
+ self.initializedVehicles = self.initializedVehicles or {}
+
+ if activeFillLevel == nil then
+ self:debugMessage(string.format("Vehicle %d has no valid fill level this frame; skipping", vehicle.id))
+ return
+ end
+
+ -- If fill unit is empty or close to empty, reset baseline
+ if activeFillLevel <= 0.1 and (self.prevFillLevels[vehicleId] or 0) > 0 then
+ self.prevFillLevels[vehicleId] = 0
+ self:debugMessage(string.format("Vehicle %d fill level is 0L; resetting baseline", vehicleId))
+ return
+ end
+
+ -- First-time detection for this vehicle: skip initial fill, no yield recorded
+ if self.initializedVehicles[vehicleId] == nil then
+ self.initializedVehicles[vehicleId] = true
+ self.prevFillLevels[vehicleId] = activeFillLevel
+ self:debugMessage(string.format("Vehicle %d seen for first time this session with %.2fL", vehicleId, activeFillLevel))
+ end
+
+ local prevLevel = self.prevFillLevels[vehicleId] or 0
+ local delta = activeFillLevel - prevLevel
+
+ if delta > 0 then
+ if self:isTrackedFillType(activeFillType) then
+ -- Harvest increase detected for a tracked crop
+ self:recordHarvest(currentYear, fieldID, cropName, delta, activeFillType)
+ self:debugMessage(string.format("Harvest detected: +%.2fL of '%s' for vehicle %d on field %d.",
+ delta, cropName, vehicleId, fieldID))
+ else
+ self:debugMessage(string.format("Skipping untracked crop type '%s' (fillTypeIndex: %d)", cropName, activeFillType))
+ end
+ end
+
+ -- Always update baseline
+ self.prevFillLevels[vehicleId] = activeFillLevel
+end
+
+
+
+
+
+-- EVENT-BASED HARVEST TRACKING
+--=======================================================================================================================================================================
+
+function YieldTracker:installFillUnitHook()
+ self:debugHarvestMessage("installFillUnitHook() called")
+
+ if self.fillUnitHookInstalled then
+ self:debugHarvestMessage("installFillUnitHook() skipped: already installed")
+ return
+ end
+
+ self:debugHarvestMessage("Vehicle exists=" .. tostring(Vehicle ~= nil))
+ self:debugHarvestMessage("Vehicle.addFillUnitFillLevel exists=" .. tostring(Vehicle ~= nil and Vehicle.addFillUnitFillLevel ~= nil))
+ self:debugHarvestMessage("FillUnit exists=" .. tostring(FillUnit ~= nil))
+ self:debugHarvestMessage("FillUnit.addFillUnitFillLevel exists=" .. tostring(FillUnit ~= nil and FillUnit.addFillUnitFillLevel ~= nil))
+
+ local tracker = self
+ local installedAnyHook = false
+
+ if Vehicle ~= nil and Vehicle.addFillUnitFillLevel ~= nil then
+ Vehicle.addFillUnitFillLevel = Utils.overwrittenFunction(Vehicle.addFillUnitFillLevel, function(vehicle, superFunc, farmId, fillUnitIndex, fillLevelDelta, fillTypeIndex, toolType, fillPositionData, ...)
+ tracker:debugHarvestMessage(string.format(
+ "HOOK_CALL Vehicle.addFillUnitFillLevel vehicle='%s' vehicleId=%s farmId=%s fillUnitIndex=%s delta=%s fillTypeIndex=%s",
+ tracker:getVehicleDebugName(vehicle),
+ tostring(vehicle ~= nil and vehicle.id or nil),
+ tostring(farmId),
+ tostring(fillUnitIndex),
+ tostring(fillLevelDelta),
+ tostring(fillTypeIndex)
+ ))
+
+ local oldFillLevel = nil
+ if vehicle ~= nil and fillUnitIndex ~= nil and vehicle.getFillUnitFillLevel ~= nil then
+ oldFillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex) or 0
+ end
+
+ local result = superFunc(vehicle, farmId, fillUnitIndex, fillLevelDelta, fillTypeIndex, toolType, fillPositionData, ...)
+
+ local appliedAmount = fillLevelDelta
+ if type(result) == "number" then
+ appliedAmount = result
+ elseif oldFillLevel ~= nil and vehicle ~= nil and vehicle.getFillUnitFillLevel ~= nil then
+ local newFillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex) or oldFillLevel
+ local measuredDelta = newFillLevel - oldFillLevel
+ tracker:debugHarvestMessage(string.format(
+ "HOOK_AFTER Vehicle.addFillUnitFillLevel old=%s new=%s measuredDelta=%s result=%s",
+ tostring(oldFillLevel), tostring(newFillLevel), tostring(measuredDelta), tostring(result)
+ ))
+ if measuredDelta > 0 then
+ appliedAmount = measuredDelta
+ end
+ end
+
+ tracker:handleHarvestFillUnitAdded(vehicle, fillUnitIndex, appliedAmount, fillTypeIndex)
+ return result
+ end)
+
+ installedAnyHook = true
+ self:debugHarvestMessage("Installed hook: Vehicle.addFillUnitFillLevel")
+ end
+
+ -- Additional trace hook in case FS25 calls the specialization table directly instead of Vehicle.*
+ if FillUnit ~= nil and FillUnit.addFillUnitFillLevel ~= nil and (Vehicle == nil or FillUnit.addFillUnitFillLevel ~= Vehicle.addFillUnitFillLevel) then
+ FillUnit.addFillUnitFillLevel = Utils.overwrittenFunction(FillUnit.addFillUnitFillLevel, function(vehicle, superFunc, farmId, fillUnitIndex, fillLevelDelta, fillTypeIndex, toolType, fillPositionData, ...)
+ tracker:debugHarvestMessage(string.format(
+ "HOOK_CALL FillUnit.addFillUnitFillLevel vehicle='%s' vehicleId=%s farmId=%s fillUnitIndex=%s delta=%s fillTypeIndex=%s",
+ tracker:getVehicleDebugName(vehicle),
+ tostring(vehicle ~= nil and vehicle.id or nil),
+ tostring(farmId),
+ tostring(fillUnitIndex),
+ tostring(fillLevelDelta),
+ tostring(fillTypeIndex)
+ ))
+
+ local oldFillLevel = nil
+ if vehicle ~= nil and fillUnitIndex ~= nil and vehicle.getFillUnitFillLevel ~= nil then
+ oldFillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex) or 0
+ end
+
+ local result = superFunc(vehicle, farmId, fillUnitIndex, fillLevelDelta, fillTypeIndex, toolType, fillPositionData, ...)
+
+ local appliedAmount = fillLevelDelta
+ if type(result) == "number" then
+ appliedAmount = result
+ elseif oldFillLevel ~= nil and vehicle ~= nil and vehicle.getFillUnitFillLevel ~= nil then
+ local newFillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex) or oldFillLevel
+ local measuredDelta = newFillLevel - oldFillLevel
+ tracker:debugHarvestMessage(string.format(
+ "HOOK_AFTER FillUnit.addFillUnitFillLevel old=%s new=%s measuredDelta=%s result=%s",
+ tostring(oldFillLevel), tostring(newFillLevel), tostring(measuredDelta), tostring(result)
+ ))
+ if measuredDelta > 0 then
+ appliedAmount = measuredDelta
+ end
+ end
+
+ tracker:handleHarvestFillUnitAdded(vehicle, fillUnitIndex, appliedAmount, fillTypeIndex)
+ return result
+ end)
+
+ installedAnyHook = true
+ self:debugHarvestMessage("Installed hook: FillUnit.addFillUnitFillLevel")
+ end
+
+ if not installedAnyHook then
+ self:debugHarvestMessage("ERROR: no fill unit hook installed; no supported addFillUnitFillLevel function found")
+ self:debugMessage("Could not install fill unit hook: no addFillUnitFillLevel function found")
+ return
+ end
+
+ self.fillUnitHookInstalled = true
+ self:debugHarvestMessage("installFillUnitHook() finished")
+ self:debugMessage("Installed fill unit harvest hook")
+end
+
+function YieldTracker:getVehicleDebugName(vehicle)
+ if vehicle == nil then
+ return "nil"
+ end
+
+ if vehicle.getName ~= nil then
+ local ok, name = pcall(function() return vehicle:getName() end)
+ if ok and name ~= nil and name ~= "" then
+ return tostring(name)
+ end
+ end
+
+ if vehicle.configFileName ~= nil then
+ return tostring(vehicle.configFileName)
+ end
+
+ if vehicle.typeName ~= nil then
+ return tostring(vehicle.typeName)
+ end
+
+ return tostring(vehicle)
+end
+
+function YieldTracker:debugHarvestMessage(message)
+ if self.debugHarvest then
+ print(PRINT_PREFIX .. "HARVEST_DEBUG - " .. tostring(message))
+ end
+end
+
+function YieldTracker:debugHarvestDecision(vehicle, fieldID, fillTypeIndex, amount, reason)
+ if not self.debugHarvest then
+ return
+ end
+
+ local vehicleName = self:getVehicleDebugName(vehicle)
+ local fillTypeName = "nil"
+ if fillTypeIndex ~= nil and g_fillTypeManager ~= nil and g_fillTypeManager.indexToFillType ~= nil then
+ local desc = g_fillTypeManager.indexToFillType[fillTypeIndex]
+ if desc ~= nil then
+ fillTypeName = tostring(desc.title or desc.name or fillTypeIndex)
+ else
+ fillTypeName = tostring(fillTypeIndex)
+ end
+ end
+
+ self:debugHarvestMessage(string.format(
+ "vehicle='%s' vehicleId=%s combine=%s baler=%s field=%s fillType=%s(%s) amount=%.2f reason=%s",
+ vehicleName,
+ tostring(vehicle ~= nil and vehicle.id or nil),
+ tostring(vehicle ~= nil and vehicle.spec_combine ~= nil),
+ tostring(vehicle ~= nil and vehicle.spec_baler ~= nil),
+ tostring(fieldID),
+ fillTypeName,
+ tostring(fillTypeIndex),
+ tonumber(amount or 0) or 0,
+ tostring(reason)
+ ))
+end
+
+function YieldTracker:handleHarvestFillUnitAdded(vehicle, fillUnitIndex, fillLevelDelta, fillTypeIndex)
+ local rawAmount = tonumber(fillLevelDelta or 0) or 0
+
+ if not self.yieldDataLoaded then
+ self:debugHarvestDecision(vehicle, nil, fillTypeIndex, rawAmount, "SKIP_yieldData_not_loaded")
+ return
+ end
+
+ if vehicle == nil or fillUnitIndex == nil or fillLevelDelta == nil or rawAmount <= 0 then
+ self:debugHarvestDecision(vehicle, nil, fillTypeIndex, rawAmount, "SKIP_invalid_or_non_positive_delta")
+ return
+ end
+
+ local isCombine = vehicle.spec_combine ~= nil
+ local isBaler = vehicle.spec_baler ~= nil
+
+ -- Count only where crop is generated. Ignore trailer/silo/transfer additions.
+ if not isCombine and not isBaler then
+ self:debugHarvestDecision(vehicle, nil, fillTypeIndex, rawAmount, "SKIP_not_combine_or_baler")
+ return
+ end
+
+ local fieldID = self:getHarvestFieldID(vehicle, isBaler)
+
+ if isCombine and not self:getIsCombineHarvesting(vehicle) then
+ self:debugHarvestDecision(vehicle, fieldID, fillTypeIndex, rawAmount, "SKIP_combine_not_harvesting")
+ return
+ end
+
+ if isBaler and not self:getIsBalerHarvesting(vehicle) then
+ self:debugHarvestDecision(vehicle, fieldID, fillTypeIndex, rawAmount, "SKIP_baler_not_harvesting")
+ return
+ end
+
+ local activeFillType = fillTypeIndex
+ if (activeFillType == nil or activeFillType == FillType.UNKNOWN) and vehicle.getFillUnitFillType ~= nil then
+ activeFillType = vehicle:getFillUnitFillType(fillUnitIndex)
+ end
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = self:getFieldFillTypeFallback(vehicle)
+ end
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "SKIP_no_active_fillType")
+ return
+ end
+
+ if not self:isTrackedFillType(activeFillType) then
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "SKIP_fillType_not_registered_fruit")
+ return
+ end
+
+ if fieldID == nil then
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "SKIP_no_fieldID")
+ return
+ end
+
+ local fillTypeDesc = g_fillTypeManager.indexToFillType[activeFillType]
+ if fillTypeDesc == nil then
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "SKIP_no_fillTypeDesc")
+ return
+ end
+
+ local currentYear = g_currentMission.environment.currentYear
+ self:recordHarvest(currentYear, fieldID, fillTypeDesc.title, rawAmount, activeFillType)
+ self:debugHarvestDecision(vehicle, fieldID, activeFillType, rawAmount, "RECORDED")
+end
+
+function YieldTracker:getIsCombineHarvesting(vehicle)
+ if vehicle == nil or vehicle.spec_combine == nil then
+ return false
+ end
+
+ local spec = vehicle.spec_combine
+ if spec.attachedCutters ~= nil then
+ for _, cutter in pairs(spec.attachedCutters) do
+ if cutter ~= nil and cutter.spec_cutter ~= nil and cutter.spec_cutter.isWorking then
+ return true
+ end
+ end
+ end
+
+ return false
+end
+
+function YieldTracker:getIsBalerHarvesting(vehicle)
+ if vehicle == nil or vehicle.spec_baler == nil then
+ return false
+ end
+
+ if vehicle.getIsTurnedOn ~= nil and vehicle:getIsTurnedOn() then
+ return true
+ end
+
+ if vehicle.getLastSpeed ~= nil and vehicle:getLastSpeed() > 0.5 then
+ return true
+ end
+
+ return true
+end
+
+function YieldTracker:getHarvestFieldID(vehicle, isBaler)
+ if isBaler then
+ local x, _, z = getWorldTranslation(vehicle.rootNode)
+ return g_farmlandManager:getFarmlandIdAtWorldPosition(x, z)
+ end
+
+ return self:getCutterFieldID(vehicle)
+end
+
+-- UTIL METHODS
+--=======================================================================================================================================================================
+
+-- Fetches the currently attached cutter's fill types.
+function YieldTracker:getCutterStatus(vehicle)
+ local spec = vehicle.spec_combine
+ if not spec then
+ return FillType.UNKNOWN, nil
+ end
+
+ if spec.attachedCutters then
+ for _, cutter in pairs(spec.attachedCutters) do
+ if cutter and cutter.spec_cutter and cutter.spec_cutter.isWorking then
+ local cutterInputFillType = cutter.spec_cutter.currentInputFillType or FillType.UNKNOWN
+ local cutterOutputFillType = cutter.spec_cutter.currentOutputFillType or FillType.UNKNOWN
+ return cutterInputFillType, cutterOutputFillType
+ end
+ end
+ end
+
+ return FillType.UNKNOWN, nil
+end
+
+
+---Fetches the current combine fill unit status.
+function YieldTracker:getCombineStatus(vehicle)
+ if not vehicle or not vehicle.spec_combine then
+ return false, FillType.UNKNOWN, 0
+ end
+
+ local spec = vehicle.spec_combine
+ local fillUnitIndex = spec.fillUnitIndex
+ local fillUnitCapacity = vehicle:getFillUnitCapacity(fillUnitIndex)
+
+ -- Combine has an Internal fill unit - case
+ if type(fillUnitCapacity) == "number" and fillUnitCapacity > 0 and fillUnitCapacity ~= math.huge then
+ -- local isFilling = spec.isFilling -- DEPRACTED
+ local fillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex) or 0
+ local combineFillType = vehicle:getFillUnitFillType(fillUnitIndex)
+ local hasInternalFillUnit = true
+ return hasInternalFillUnit, combineFillType, fillLevel
+ end
+
+ -- Discharge to external target (fillUnitCapacity <= 0 or is inf)
+ local dischargeSpec = vehicle.spec_combine.spec_dischargeable
+ if dischargeSpec then
+ for i, dischargeNode in ipairs(dischargeSpec.dischargeNodes) do
+ local targetObject, targetFillUnitIndex = vehicle:getDischargeTargetObject(dischargeNode)
+
+ if targetObject and targetFillUnitIndex then
+ local fillLevel = targetObject:getFillUnitFillLevel(targetFillUnitIndex)
+ local targetFillType = targetObject:getFillUnitFillType(targetFillUnitIndex)
+ local hasInternalFillUnit = false
+ return hasInternalFillUnit, targetFillType, fillLevel
+ end
+ end
+ end
+
+
+ return nil, nil, nil
+end
+
+
+-- Fallback method to determine the field's fill type when the fill unit is empty but the cutter is active.
+function YieldTracker:getFieldFillTypeFallback(vehicle)
+ local cutterFieldID = self:getCutterFieldID(vehicle)
+ if not cutterFieldID then
+ return nil
+ end
+
+ local field = g_fieldManager.fields[cutterFieldID]
+ if not field then
+ return nil
+ end
+
+ local fruitTypeIndex = field.fieldState.fruitTypeIndex
+ if fruitTypeIndex == nil or fruitTypeIndex == 0 then
+ return nil
+ end
+
+ local fruitType = g_fruitTypeManager:getFruitTypeByIndex(fruitTypeIndex)
+ if fruitType then
+ return fruitType.fillType
+ end
+
+ return FillType.UNKNOWN
+end
+
+
+-- Determines fill type based on primary, secondary triggers, or fallback (cutter input/output, combine fill unit, and field fill type).
+function YieldTracker:getActiveFillType(primary, secondary, fallback)
+ if primary and primary ~= FillType.UNKNOWN then
+ return primary
+ elseif secondary and secondary ~= FillType.UNKNOWN then
+ return secondary
+ elseif fallback and fallback ~= FillType.Unknow then
+ return fallback -- Field fillType
+ end
+ return FillType.UNKNOWN
+end
+
+
+-- Uses the cutter's position to determine the current field being harvested.
+function YieldTracker:getCutterFieldID(vehicle)
+ local spec_combine = vehicle.spec_combine
+ if not spec_combine then return nil end
+
+ for _, cutter in pairs(spec_combine.attachedCutters) do
+ if cutter and cutter.spec_cutter then
+ local x, y, z = getWorldTranslation(cutter.rootNode)
+ local fieldID = g_farmlandManager:getFarmlandIdAtWorldPosition(x, z)
+ return fieldID
+ end
+ end
+ return nil
+end
+
+
+-- Checks if a given fill type index is in the tracked crop types.
+function YieldTracker:isTrackedFillType(fillTypeIndex)
+ if fillTypeIndex == nil then
+ return false
+ end
+
+ if g_fruitTypeManager == nil or g_fruitTypeManager.getFruitTypes == nil then
+ return false
+ end
+
+ for _, fruitType in ipairs(g_fruitTypeManager:getFruitTypes()) do
+ if fruitType ~= nil then
+ local fillType = fruitType.fillType
+ local fruitFillTypeIndex = fruitType.fillTypeIndex
+
+ if fillType ~= nil then
+ fruitFillTypeIndex = fillType.index or fillType.fillTypeIndex or fruitFillTypeIndex
+ end
+
+ if fruitFillTypeIndex == fillTypeIndex then
+ return true
+ end
+ end
+ end
+
+ return false
+end
+
+
+---Updates the yieldData table with the latest harvested amount. Also referenced during game save.
+function YieldTracker:recordHarvest(year, fieldId, cropName, amount, fillTypeIndex)
+ self.yieldData[year] = self.yieldData[year] or {}
+ self.yieldData[year][fieldId] = self.yieldData[year][fieldId] or {}
+
+ local fillTypeEntry = self.yieldData[year][fieldId][fillTypeIndex]
+ if not fillTypeEntry then
+ self.yieldData[year][fieldId][fillTypeIndex] = {
+ amount = 0,
+ fillTypeTitle = cropName
+ }
+ fillTypeEntry = self.yieldData[year][fieldId][fillTypeIndex]
+ end
+
+ fillTypeEntry.amount = fillTypeEntry.amount + amount
+
+ self:debugMessage(string.format(
+ "Recorded %.2fL of %s (fillTypeIndex=%d) on field %d (year %d)",
+ amount, cropName, fillTypeIndex, fieldId, year
+ ))
+
+end
+
+
+-- Prints a debug message if debugging is enabled.
+function YieldTracker:debugMessage(message)
+ if self.debug then
+ print(PRINT_PREFIX .. message)
+ end
+end
+
+--=======================================================================================================================================================================
+
+
+
+
+
+-- COMBINE FILL LEVEL DELTA DEBUG HOOK
+--=======================================================================================================================================================================
+function YieldTracker:installCombineFillLevelDeltaDebugHook()
+ self:debugHarvestMessage("installCombineFillLevelDeltaDebugHook() called")
+
+ if self.combineFillLevelDeltaDebugHookInstalled then
+ self:debugHarvestMessage("installCombineFillLevelDeltaDebugHook() skipped: already installed")
+ return
+ end
+
+ self.combineFillLevelDebugCallCount = self.combineFillLevelDebugCallCount or {}
+ self.combineFillLevelDebugMaxIdleLogsPerVehicle = 25
+ self.combinePreviousFillLevels = self.combinePreviousFillLevels or {}
+ self.combinePreviousFillTypes = self.combinePreviousFillTypes or {}
+
+ self:debugHarvestMessage("DELTA_EXISTS Combine=" .. tostring(Combine ~= nil))
+ self:debugHarvestMessage("DELTA_EXISTS Combine.onUpdateTick=" .. tostring(Combine ~= nil and type(Combine.onUpdateTick) == "function"))
+ self:debugHarvestMessage("DELTA_EXISTS Combine.updateTick=" .. tostring(Combine ~= nil and type(Combine.updateTick) == "function"))
+
+ if Combine == nil or type(Combine.onUpdateTick) ~= "function" then
+ self:debugHarvestMessage("ERROR: Combine.onUpdateTick not found; combine delta debug hook not installed")
+ return
+ end
+
+ local tracker = self
+
+ Combine.onUpdateTick = Utils.overwrittenFunction(Combine.onUpdateTick, function(vehicle, superFunc, dt, ...)
+ local result = superFunc(vehicle, dt, ...)
+
+ -- Compare against the previous onUpdateTick call, not against a value
+ -- read earlier in the same tick. In FS25 the combine fill level can
+ -- already be updated before Combine.onUpdateTick is entered.
+ local currentLevel, currentFillType, fillUnitIndex = tracker:getCombineInternalFillState(vehicle)
+ local vehicleId = vehicle ~= nil and (vehicle.id or tostring(vehicle)) or nil
+ local key = nil
+
+ if vehicleId ~= nil and fillUnitIndex ~= nil then
+ key = tostring(vehicleId) .. ":" .. tostring(fillUnitIndex)
+ end
+
+ local previousLevel = nil
+ local previousFillType = nil
+
+ if key ~= nil then
+ previousLevel = tracker.combinePreviousFillLevels[key]
+ previousFillType = tracker.combinePreviousFillTypes[key]
+ end
+
+ local delta = nil
+ if previousLevel ~= nil and currentLevel ~= nil then
+ delta = currentLevel - previousLevel
+ end
+
+ tracker:handleCombineFillLevelDeltaDebug(vehicle, fillUnitIndex, previousLevel, currentLevel, delta, previousFillType, currentFillType)
+
+ -- Update baseline only after logging/recording.
+ if key ~= nil and currentLevel ~= nil then
+ tracker.combinePreviousFillLevels[key] = currentLevel
+ tracker.combinePreviousFillTypes[key] = currentFillType
+ end
+
+ return result
+ end)
+
+ self.combineFillLevelDeltaDebugHookInstalled = true
+ self:debugHarvestMessage("DELTA_INSTALLED Combine.onUpdateTick")
+ self:debugHarvestMessage("installCombineFillLevelDeltaDebugHook() finished")
+end
+
+function YieldTracker:getCombineInternalFillState(vehicle)
+ if vehicle == nil or vehicle.spec_combine == nil then
+ return nil, nil, nil
+ end
+
+ local spec = vehicle.spec_combine
+ local fillUnitIndex = spec.fillUnitIndex
+ if fillUnitIndex == nil then
+ return nil, nil, nil
+ end
+
+ local fillLevel = nil
+ local fillType = nil
+
+ if vehicle.getFillUnitFillLevel ~= nil then
+ fillLevel = vehicle:getFillUnitFillLevel(fillUnitIndex)
+ end
+
+ if vehicle.getFillUnitFillType ~= nil then
+ fillType = vehicle:getFillUnitFillType(fillUnitIndex)
+ end
+
+ return fillLevel, fillType, fillUnitIndex
+end
+
+function YieldTracker:handleCombineFillLevelDeltaDebug(vehicle, fillUnitIndex, oldLevel, newLevel, delta, oldFillType, newFillType)
+ if not self.debugHarvest then
+ return
+ end
+
+ if vehicle == nil then
+ self:debugHarvestMessage("DELTA_SKIP vehicle=nil")
+ return
+ end
+
+ local vehicleId = vehicle.id or tostring(vehicle)
+ self.combineFillLevelDebugCallCount[vehicleId] = (self.combineFillLevelDebugCallCount[vehicleId] or 0) + 1
+
+ local fieldID = self:getCutterFieldID(vehicle)
+ local cutterInputFillType, cutterOutputFillType = self:getCutterStatus(vehicle)
+ local activeFillType = self:getActiveFillType(newFillType, cutterOutputFillType, cutterInputFillType)
+
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = self:getFieldFillTypeFallback(vehicle)
+ end
+
+ local fillTypeTitle = self:getFillTypeDebugTitle(activeFillType)
+ local vehicleName = self:getVehicleDebugName(vehicle)
+ local amount = tonumber(delta or 0) or 0
+
+ if newLevel == nil then
+ self:debugHarvestMessage(string.format(
+ "DELTA_SKIP vehicle='%s' vehicleId=%s field=%s fillUnit=%s old=%s new=%s delta=%s activeFillType=%s(%s) reason=no_current_fill_state",
+ vehicleName, tostring(vehicleId), tostring(fieldID), tostring(fillUnitIndex), tostring(oldLevel), tostring(newLevel), tostring(delta), fillTypeTitle, tostring(activeFillType)
+ ))
+ return
+ end
+
+ if oldLevel == nil then
+ self:debugHarvestMessage(string.format(
+ "COMBINE_DELTA_BASELINE vehicle='%s' vehicleId=%s field=%s fillUnit=%s current=%.2f newFillType=%s activeFillType=%s(%s) reason=baseline_initialized",
+ vehicleName,
+ tostring(vehicleId),
+ tostring(fieldID),
+ tostring(fillUnitIndex),
+ tonumber(newLevel or 0) or 0,
+ tostring(newFillType),
+ fillTypeTitle,
+ tostring(activeFillType)
+ ))
+ return
+ end
+
+ if amount > 0.001 then
+ local reason = "DELTA_POSITIVE"
+ local recorded = false
+
+ if not self.yieldDataLoaded then
+ reason = "DELTA_POSITIVE_SKIP_yieldData_not_loaded"
+ elseif fieldID == nil then
+ reason = "DELTA_POSITIVE_SKIP_no_fieldID"
+ elseif activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ reason = "DELTA_POSITIVE_SKIP_no_active_fillType"
+ elseif not self:isTrackedFillType(activeFillType) then
+ reason = "DELTA_POSITIVE_SKIP_fillType_not_registered_fruit"
+ else
+ local fillTypeDesc = g_fillTypeManager.indexToFillType[activeFillType]
+ local cropName = fillTypeDesc ~= nil and (fillTypeDesc.title or fillTypeDesc.name or tostring(activeFillType)) or tostring(activeFillType)
+ local currentYear = g_currentMission.environment.currentYear
+ self:recordHarvest(currentYear, fieldID, cropName, amount, activeFillType)
+ reason = "DELTA_POSITIVE_RECORDED"
+ recorded = true
+ end
+
+ self:debugHarvestMessage(string.format(
+ "COMBINE_DELTA vehicle='%s' vehicleId=%s field=%s fillUnit=%s old=%.2f new=%.2f delta=%.2f oldFillType=%s newFillType=%s cutterInput=%s cutterOutput=%s activeFillType=%s(%s) recorded=%s reason=%s",
+ vehicleName,
+ tostring(vehicleId),
+ tostring(fieldID),
+ tostring(fillUnitIndex),
+ tonumber(oldLevel or 0) or 0,
+ tonumber(newLevel or 0) or 0,
+ amount,
+ tostring(oldFillType),
+ tostring(newFillType),
+ tostring(cutterInputFillType),
+ tostring(cutterOutputFillType),
+ fillTypeTitle,
+ tostring(activeFillType),
+ tostring(recorded),
+ reason
+ ))
+ else
+ local count = self.combineFillLevelDebugCallCount[vehicleId] or 0
+ if count <= self.combineFillLevelDebugMaxIdleLogsPerVehicle or count % 300 == 0 then
+ self:debugHarvestMessage(string.format(
+ "COMBINE_DELTA_IDLE vehicle='%s' vehicleId=%s field=%s fillUnit=%s old=%.2f new=%.2f delta=%.2f newFillType=%s activeFillType=%s(%s) call=%d",
+ vehicleName,
+ tostring(vehicleId),
+ tostring(fieldID),
+ tostring(fillUnitIndex),
+ tonumber(oldLevel or 0) or 0,
+ tonumber(newLevel or 0) or 0,
+ amount,
+ tostring(newFillType),
+ self:getFillTypeDebugTitle(activeFillType),
+ tostring(activeFillType),
+ count
+ ))
+ end
+ end
+end
+
+function YieldTracker:getFillTypeDebugTitle(fillTypeIndex)
+ if fillTypeIndex == nil then
+ return "nil"
+ end
+
+ if g_fillTypeManager ~= nil and g_fillTypeManager.indexToFillType ~= nil then
+ local desc = g_fillTypeManager.indexToFillType[fillTypeIndex]
+ if desc ~= nil then
+ return tostring(desc.title or desc.name or fillTypeIndex)
+ end
+ end
+
+ return tostring(fillTypeIndex)
+end
+
+
+
+-- REDUCED PIPE TRACKING HOOK
+-- Tracks only the most likely pipe transfer path: Dischargeable.onUpdateTick.
+-- Debug output is restricted to harvest vehicles only.
+-- Pipe-only comparison formula:
+-- pipeHarvestAmount = targetDelta
+-- targetDelta = liters added to the overload/trailer target during this discharge tick
+-- Tank drawdown is NOT subtracted anymore. For the pipe-only approach, every liter that
+-- leaves the combine/harvester pipe is relevant.
+function YieldTracker:installPipeDischargeTrackingHook()
+ self:debugHarvestMessage("installPipeDischargeTrackingHook() called")
+
+ if self.pipeDischargeTrackingHookInstalled then
+ self:debugHarvestMessage("installPipeDischargeTrackingHook() skipped: already installed")
+ return
+ end
+
+ self.pipeDischargeCallCount = self.pipeDischargeCallCount or {}
+ self.pipeDischargeMaxIdleLogsPerVehicle = 20
+
+ self:debugHarvestMessage("PIPE_ONLY_DEBUG_V2_EXISTS Dischargeable=" .. tostring(Dischargeable ~= nil))
+ self:debugHarvestMessage("PIPE_ONLY_DEBUG_V2_EXISTS Dischargeable.onUpdateTick=" .. tostring(Dischargeable ~= nil and type(Dischargeable.onUpdateTick) == "function"))
+
+ if Dischargeable == nil or type(Dischargeable.onUpdateTick) ~= "function" then
+ self:debugHarvestMessage("ERROR: Dischargeable.onUpdateTick not found; pipe tracking hook not installed")
+ return
+ end
+
+ local tracker = self
+
+ Dischargeable.onUpdateTick = Utils.overwrittenFunction(Dischargeable.onUpdateTick, function(vehicle, superFunc, dt, ...)
+ local args = {...}
+ local beforeCombineLevel, beforeCombineFillType, combineFillUnitIndex = tracker:getCombineInternalFillState(vehicle)
+ local beforeTargetObject, beforeTargetFillUnitIndex, beforeTargetLevel, beforeTargetFillType = tracker:getPipeTargetState(vehicle, args)
+
+ local result = superFunc(vehicle, dt, ...)
+
+ local afterCombineLevel, afterCombineFillType = tracker:getCombineInternalFillState(vehicle)
+ local afterTargetObject, afterTargetFillUnitIndex, afterTargetLevel, afterTargetFillType = tracker:getPipeTargetState(vehicle, args, beforeTargetObject, beforeTargetFillUnitIndex)
+
+ tracker:handlePipeDischargeTracking(
+ vehicle,
+ dt,
+ beforeTargetObject,
+ beforeTargetFillUnitIndex,
+ beforeTargetLevel,
+ beforeTargetFillType,
+ afterTargetObject,
+ afterTargetFillUnitIndex,
+ afterTargetLevel,
+ afterTargetFillType,
+ combineFillUnitIndex,
+ beforeCombineLevel,
+ beforeCombineFillType,
+ afterCombineLevel,
+ afterCombineFillType
+ )
+
+ return result
+ end)
+
+ self.pipeDischargeTrackingHookInstalled = true
+ self:debugHarvestMessage("PIPE_ONLY_DEBUG_V2_INSTALLED Dischargeable.onUpdateTick")
+ self:debugHarvestMessage("installPipeDischargeTrackingHook() finished")
+end
+
+function YieldTracker:getPipeTargetState(vehicle, args, knownTargetObject, knownTargetFillUnitIndex)
+ if knownTargetObject ~= nil and knownTargetFillUnitIndex ~= nil and knownTargetObject.getFillUnitFillLevel ~= nil then
+ local level = knownTargetObject:getFillUnitFillLevel(knownTargetFillUnitIndex)
+ local fillType = nil
+ if knownTargetObject.getFillUnitFillType ~= nil then
+ fillType = knownTargetObject:getFillUnitFillType(knownTargetFillUnitIndex)
+ end
+ return knownTargetObject, knownTargetFillUnitIndex, level, fillType
+ end
+
+ if vehicle == nil or vehicle.getDischargeTargetObject == nil then
+ return nil, nil, nil, nil
+ end
+
+ if args ~= nil then
+ for _, possibleDischargeNode in ipairs(args) do
+ local ok, targetObject, targetFillUnitIndex = pcall(function()
+ return vehicle:getDischargeTargetObject(possibleDischargeNode)
+ end)
+
+ if ok and targetObject ~= nil and targetFillUnitIndex ~= nil then
+ local level = nil
+ local fillType = nil
+ if targetObject.getFillUnitFillLevel ~= nil then
+ level = targetObject:getFillUnitFillLevel(targetFillUnitIndex)
+ end
+ if targetObject.getFillUnitFillType ~= nil then
+ fillType = targetObject:getFillUnitFillType(targetFillUnitIndex)
+ end
+ return targetObject, targetFillUnitIndex, level, fillType
+ end
+ end
+ end
+
+ local dischargeSpec = vehicle.spec_dischargeable or (vehicle.spec_combine ~= nil and vehicle.spec_combine.spec_dischargeable) or nil
+ if dischargeSpec ~= nil and dischargeSpec.dischargeNodes ~= nil then
+ for _, dischargeNode in pairs(dischargeSpec.dischargeNodes) do
+ local ok, targetObject, targetFillUnitIndex = pcall(function()
+ return vehicle:getDischargeTargetObject(dischargeNode)
+ end)
+
+ if ok and targetObject ~= nil and targetFillUnitIndex ~= nil then
+ local level = nil
+ local fillType = nil
+ if targetObject.getFillUnitFillLevel ~= nil then
+ level = targetObject:getFillUnitFillLevel(targetFillUnitIndex)
+ end
+ if targetObject.getFillUnitFillType ~= nil then
+ fillType = targetObject:getFillUnitFillType(targetFillUnitIndex)
+ end
+ return targetObject, targetFillUnitIndex, level, fillType
+ end
+ end
+ end
+
+ return nil, nil, nil, nil
+end
+
+
+function YieldTracker:isPipeDebugHarvestVehicle(vehicle)
+ if vehicle == nil then
+ return false
+ end
+
+ -- Normal combine harvesters and many specialized harvesters use spec_combine.
+ if vehicle.spec_combine ~= nil then
+ return true
+ end
+
+ -- Fallback for modded harvesters that expose a harvester specialization/name instead of spec_combine.
+ if vehicle.spec_harvester ~= nil or vehicle.spec_combineDrivable ~= nil then
+ return true
+ end
+
+ local typeName = nil
+ if vehicle.typeName ~= nil then
+ typeName = tostring(vehicle.typeName):lower()
+ elseif vehicle.configFileName ~= nil then
+ typeName = tostring(vehicle.configFileName):lower()
+ end
+
+ if typeName ~= nil then
+ if typeName:find("combine", 1, true) ~= nil or typeName:find("harvester", 1, true) ~= nil then
+ return true
+ end
+ end
+
+ return false
+end
+
+function YieldTracker:handlePipeDischargeTracking(vehicle, dt, beforeTargetObject, beforeTargetFillUnitIndex, beforeTargetLevel, beforeTargetFillType, afterTargetObject, afterTargetFillUnitIndex, afterTargetLevel, afterTargetFillType, combineFillUnitIndex, beforeCombineLevel, beforeCombineFillType, afterCombineLevel, afterCombineFillType)
+ if vehicle == nil then
+ return
+ end
+
+ if not self:isPipeDebugHarvestVehicle(vehicle) then
+ return
+ end
+
+ if not self.yieldDataLoaded then
+ return
+ end
+
+ local targetDelta = 0
+ if beforeTargetLevel ~= nil and afterTargetLevel ~= nil then
+ targetDelta = (tonumber(afterTargetLevel) or 0) - (tonumber(beforeTargetLevel) or 0)
+ end
+
+ if targetDelta <= 0.001 then
+ return
+ end
+
+ local fieldID = self:getCutterFieldID(vehicle)
+ if fieldID == nil then
+ return
+ end
+
+ local cutterInputFillType, cutterOutputFillType = self:getCutterStatus(vehicle)
+ local activeFillType = self:getActiveFillType(afterCombineFillType, afterTargetFillType, cutterOutputFillType)
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = self:getActiveFillType(cutterOutputFillType, cutterInputFillType, beforeCombineFillType)
+ end
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = self:getFieldFillTypeFallback(vehicle)
+ end
+
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN or not self:isTrackedFillType(activeFillType) then
+ return
+ end
+
+ local currentYear = g_currentMission.environment.currentYear
+ self:addPipeHarvestAmount(currentYear, fieldID, activeFillType, targetDelta)
+
+ -- After a successful pipe transfer, the combine tank usually changed too.
+ -- Refresh the transient tank contribution so the GUI shows pipe + remaining tank.
+ self:updateCombineLiveTank(vehicle, fieldID, activeFillType)
+end
+
+
+
+-- PIPE-BASED FINAL HARVEST CALCULATION
+--=======================================================================================================================================================================
+function YieldTracker:makeYieldKey(year, fieldID, fillTypeIndex)
+ if year == nil or fieldID == nil or fillTypeIndex == nil then
+ return nil
+ end
+ return tostring(year) .. ":" .. tostring(fieldID) .. ":" .. tostring(fillTypeIndex)
+end
+
+function YieldTracker:getFillTypeTitle(fillTypeIndex)
+ local fillTypeDesc = g_fillTypeManager ~= nil and g_fillTypeManager.indexToFillType ~= nil and g_fillTypeManager.indexToFillType[fillTypeIndex] or nil
+ if fillTypeDesc ~= nil then
+ return fillTypeDesc.title or fillTypeDesc.name or tostring(fillTypeIndex)
+ end
+ return tostring(fillTypeIndex)
+end
+
+function YieldTracker:initializePipeBaseFromYieldData()
+ self.pipeBaseTotals = {}
+ self.liveTankContributions = {}
+ self.vehicleLiveTankKeys = {}
+
+ local liveByKey = {}
+ if self.savedLiveTankContributions ~= nil then
+ for _, entry in pairs(self.savedLiveTankContributions) do
+ local key = self:makeYieldKey(entry.year, entry.fieldID, entry.fillTypeIndex)
+ if key ~= nil then
+ liveByKey[key] = (liveByKey[key] or 0) + (tonumber(entry.amount) or 0)
+ end
+ end
+ end
+
+ for year, fields in pairs(self.yieldData or {}) do
+ self.pipeBaseTotals[year] = self.pipeBaseTotals[year] or {}
+ for fieldID, crops in pairs(fields) do
+ self.pipeBaseTotals[year][fieldID] = self.pipeBaseTotals[year][fieldID] or {}
+ for fillTypeIndex, cropData in pairs(crops) do
+ local amount = tonumber(cropData.amount or 0) or 0
+ local key = self:makeYieldKey(year, fieldID, fillTypeIndex)
+ local liveAmount = liveByKey[key] or 0
+ self.pipeBaseTotals[year][fieldID][fillTypeIndex] = math.max(0, amount - liveAmount)
+ end
+ end
+ end
+
+ self.savedLiveTankContributions = nil
+end
+
+function YieldTracker:getPipeBaseAmount(year, fieldID, fillTypeIndex)
+ if self.pipeBaseTotals == nil or self.pipeBaseTotals[year] == nil or self.pipeBaseTotals[year][fieldID] == nil then
+ return 0
+ end
+ return tonumber(self.pipeBaseTotals[year][fieldID][fillTypeIndex] or 0) or 0
+end
+
+function YieldTracker:getLiveTankAmountForKey(key)
+ local total = 0
+ for _, contribution in pairs(self.liveTankContributions or {}) do
+ if contribution.key == key then
+ total = total + (tonumber(contribution.amount) or 0)
+ end
+ end
+ return total
+end
+
+function YieldTracker:syncYieldDataEntry(year, fieldID, fillTypeIndex)
+ if year == nil or fieldID == nil or fillTypeIndex == nil then
+ return
+ end
+
+ self.yieldData[year] = self.yieldData[year] or {}
+ self.yieldData[year][fieldID] = self.yieldData[year][fieldID] or {}
+
+ local key = self:makeYieldKey(year, fieldID, fillTypeIndex)
+ local amount = self:getPipeBaseAmount(year, fieldID, fillTypeIndex) + self:getLiveTankAmountForKey(key)
+
+ self.yieldData[year][fieldID][fillTypeIndex] = self.yieldData[year][fieldID][fillTypeIndex] or {
+ amount = 0,
+ fillTypeTitle = self:getFillTypeTitle(fillTypeIndex)
+ }
+
+ self.yieldData[year][fieldID][fillTypeIndex].amount = amount
+ self.yieldData[year][fieldID][fillTypeIndex].fillTypeTitle = self:getFillTypeTitle(fillTypeIndex)
+end
+
+function YieldTracker:addPipeHarvestAmount(year, fieldID, fillTypeIndex, amount)
+ amount = tonumber(amount or 0) or 0
+ if year == nil or fieldID == nil or fillTypeIndex == nil or amount <= 0.001 then
+ return
+ end
+
+ self.pipeBaseTotals = self.pipeBaseTotals or {}
+ self.pipeBaseTotals[year] = self.pipeBaseTotals[year] or {}
+ self.pipeBaseTotals[year][fieldID] = self.pipeBaseTotals[year][fieldID] or {}
+ self.pipeBaseTotals[year][fieldID][fillTypeIndex] = (self.pipeBaseTotals[year][fieldID][fillTypeIndex] or 0) + amount
+
+ self:syncYieldDataEntry(year, fieldID, fillTypeIndex)
+end
+
+function YieldTracker:updateCombineLiveTank(vehicle, fieldID, fillTypeIndex)
+ if vehicle == nil or not self.yieldDataLoaded then
+ return
+ end
+
+ local year = g_currentMission.environment.currentYear
+ local vehicleId = vehicle.id or tostring(vehicle)
+ local oldContribution = self.liveTankContributions ~= nil and self.liveTankContributions[vehicleId] or nil
+
+ if oldContribution ~= nil then
+ self.liveTankContributions[vehicleId] = nil
+ self:syncYieldDataEntry(oldContribution.year, oldContribution.fieldID, oldContribution.fillTypeIndex)
+ end
+
+ local tankLevel = 0
+ local tankFillType = fillTypeIndex
+ local fillLevel, currentFillType = self:getCombineInternalFillState(vehicle)
+ if fillLevel ~= nil then
+ tankLevel = tonumber(fillLevel or 0) or 0
+ end
+ if currentFillType ~= nil and currentFillType ~= FillType.UNKNOWN then
+ tankFillType = currentFillType
+ end
+
+ if tankLevel <= 0.001 then
+ return
+ end
+
+ if fieldID == nil or fillTypeIndex == nil or fillTypeIndex == FillType.UNKNOWN then
+ return
+ end
+
+ local key = self:makeYieldKey(year, fieldID, fillTypeIndex)
+ self.liveTankContributions = self.liveTankContributions or {}
+ self.liveTankContributions[vehicleId] = {
+ key = key,
+ year = year,
+ fieldID = fieldID,
+ fillTypeIndex = fillTypeIndex,
+ amount = tankLevel
+ }
+
+ self:syncYieldDataEntry(year, fieldID, fillTypeIndex)
+end
+
+function YieldTracker:installCombineLiveTankTrackingHook()
+ if self.combineLiveTankTrackingHookInstalled then
+ return
+ end
+
+ if Combine == nil or type(Combine.onUpdateTick) ~= "function" then
+ return
+ end
+
+ local tracker = self
+ Combine.onUpdateTick = Utils.overwrittenFunction(Combine.onUpdateTick, function(vehicle, superFunc, dt, ...)
+ local result = superFunc(vehicle, dt, ...)
+
+ if tracker:isPipeDebugHarvestVehicle(vehicle) then
+ local fieldID = tracker:getCutterFieldID(vehicle)
+ local currentLevel, currentFillType = tracker:getCombineInternalFillState(vehicle)
+ local cutterInputFillType, cutterOutputFillType = tracker:getCutterStatus(vehicle)
+ local activeFillType = tracker:getActiveFillType(currentFillType, cutterOutputFillType, cutterInputFillType)
+ if activeFillType == nil or activeFillType == FillType.UNKNOWN then
+ activeFillType = tracker:getFieldFillTypeFallback(vehicle)
+ end
+
+ if fieldID ~= nil and activeFillType ~= nil and activeFillType ~= FillType.UNKNOWN and tracker:isTrackedFillType(activeFillType) then
+ tracker:updateCombineLiveTank(vehicle, fieldID, activeFillType)
+ elseif currentLevel ~= nil and currentLevel <= 0.001 then
+ tracker:updateCombineLiveTank(vehicle, nil, nil)
+ end
+ end
+
+ return result
+ end)
+
+ self.combineLiveTankTrackingHookInstalled = true
+end
+
+-- PIPE-ONLY DEBUG CALCULATION
+--=======================================================================================================================================================================
+function YieldTracker:addPipeOnlyDebugAmount(year, fieldID, fillTypeIndex, amount)
+ if year == nil or fieldID == nil or fillTypeIndex == nil then
+ return
+ end
+
+ amount = tonumber(amount or 0) or 0
+ if amount <= 0.001 then
+ return
+ end
+
+ self.pipeOnlyDebugTotals = self.pipeOnlyDebugTotals or {}
+ self.pipeOnlyDebugTotals[year] = self.pipeOnlyDebugTotals[year] or {}
+ self.pipeOnlyDebugTotals[year][fieldID] = self.pipeOnlyDebugTotals[year][fieldID] or {}
+ self.pipeOnlyDebugTotals[year][fieldID][fillTypeIndex] = (self.pipeOnlyDebugTotals[year][fieldID][fillTypeIndex] or 0) + amount
+end
+
+function YieldTracker:getPipeOnlyDebugAmount(year, fieldID, fillTypeIndex)
+ if self.pipeOnlyDebugTotals == nil then
+ return 0
+ end
+
+ if self.pipeOnlyDebugTotals[year] == nil or self.pipeOnlyDebugTotals[year][fieldID] == nil then
+ return 0
+ end
+
+ return self.pipeOnlyDebugTotals[year][fieldID][fillTypeIndex] or 0
+end
+
+function YieldTracker:getCurrentCombineTankForDebug(vehicle, expectedFillType)
+ local level, fillType, fillUnitIndex = self:getCombineInternalFillState(vehicle)
+
+ if expectedFillType ~= nil and fillType ~= nil and fillType ~= expectedFillType then
+ return 0, fillType, fillUnitIndex
+ end
+
+ return tonumber(level or 0) or 0, fillType, fillUnitIndex
+end
+
+function YieldTracker:formatDebugNumber(value)
+ if value == nil then
+ return "nil"
+ end
+ local numberValue = tonumber(value)
+ if numberValue == nil then
+ return tostring(value)
+ end
+ return string.format("%.2f", numberValue)
+end
+
+
+-- SAVE/LOAD METHODS
+--=======================================================================================================================================================================
+
+-- Call required dependencies upon loadmap from main.lua
+function YieldTracker:setup()
+ self:loadYieldData()
+ self:initializePipeBaseFromYieldData()
+ self:installCombineLiveTankTrackingHook()
+ self:installPipeDischargeTrackingHook()
+ self:hookInGameSaveEvent()
+end
+
+
+-- Hooks into the save event to trigger yield data saving on every game save.
+function YieldTracker:hookInGameSaveEvent()
+ FSCareerMissionInfo.saveToXMLFile = Utils.appendedFunction(
+ FSCareerMissionInfo.saveToXMLFile,
+ function() YieldTracker:saveYieldData() end)
+end
+
+
+-- Loads yield tracking data and vehicle baseline fill levels from an XML file in the current savegame directory.
+-- Rebuilds the yieldData structure by year, field, and crop, and restores previous fill levels per vehicle.
+-- If no file exists or loading fails, initializes with empty data and marks data as loaded.
+function YieldTracker:loadYieldData()
+ if not g_currentMission or not g_currentMission.missionInfo.savegameDirectory then
+ self:debugMessage("Load skipped: no savegame directory")
+ self.yieldDataLoaded = true
+ return
+ end
+
+ local filePath = g_currentMission.missionInfo.savegameDirectory .. "/" .. self.saveFileName
+ if not fileExists(filePath) then
+ self:debugMessage("No existing yield data XML found")
+ self.yieldDataLoaded = true
+ return
+ end
+
+ local xmlFile = loadXMLFile("YieldDataXML", filePath)
+ if not xmlFile then
+ self:debugMessage("Failed to load yield data XML")
+ return
+ end
+
+ self.yieldData = {}
+
+ local i = 0
+ while true do
+ local yearKey = string.format("yieldData.year(%d)", i)
+ if not hasXMLProperty(xmlFile, yearKey) then break end
+
+ local year = getXMLInt(xmlFile, yearKey .. "#value")
+ self.yieldData[year] = {}
+
+ local j = 0
+ while true do
+ local fieldKey = string.format("%s.field(%d)", yearKey, j)
+ if not hasXMLProperty(xmlFile, fieldKey) then break end
+
+ local fieldId = getXMLInt(xmlFile, fieldKey .. "#id")
+ self.yieldData[year][fieldId] = {}
+
+ local k = 0
+ while true do
+ local cropKey = string.format("%s.crop(%d)", fieldKey, k)
+ if not hasXMLProperty(xmlFile, cropKey) then break end
+
+ local cropName = getXMLString(xmlFile, cropKey .. "#name")
+ local amount = getXMLFloat(xmlFile, cropKey .. "#amount")
+ local fillTypeIndex = getXMLInt(xmlFile, cropKey .. "#fillTypeIndex")
+
+ self.yieldData[year][fieldId][fillTypeIndex] = {
+ amount = amount,
+ fillTypeTitle = cropName
+ }
+
+ k = k + 1
+ end
+
+ j = j + 1
+ end
+
+ i = i + 1
+ end
+
+ -- Load prevFillLevels baseline per vehicle
+ self.prevFillLevels = {}
+ local v = 0
+ while true do
+ local vehicleKey = string.format("yieldData.prevFillLevels.vehicle(%d)", v)
+ if not hasXMLProperty(xmlFile, vehicleKey) then break end
+
+ local vehicleId = getXMLInt(xmlFile, vehicleKey .. "#id")
+ local level = getXMLFloat(xmlFile, vehicleKey .. "#level")
+
+ if vehicleId and level then
+ self.prevFillLevels[vehicleId] = level
+ end
+
+ v = v + 1
+ end
+
+
+ -- Load transient live tank contributions that were included in the saved display amount.
+ -- They are subtracted from pipeBaseTotals after loading to avoid double counting when the same tank is unloaded after reload.
+ self.savedLiveTankContributions = {}
+ local lt = 0
+ while true do
+ local liveKey = string.format("yieldData.liveTankContributions.vehicle(%d)", lt)
+ if not hasXMLProperty(xmlFile, liveKey) then break end
+
+ table.insert(self.savedLiveTankContributions, {
+ vehicleId = getXMLString(xmlFile, liveKey .. "#id"),
+ year = getXMLInt(xmlFile, liveKey .. "#year"),
+ fieldID = getXMLInt(xmlFile, liveKey .. "#fieldId"),
+ fillTypeIndex = getXMLInt(xmlFile, liveKey .. "#fillTypeIndex"),
+ amount = getXMLFloat(xmlFile, liveKey .. "#amount") or 0
+ })
+
+ lt = lt + 1
+ end
+
+ delete(xmlFile)
+
+ self.yieldDataLoaded = true
+ self:debugMessage("Yield data loaded from XML")
+end
+
+
+
+-- Saves yield tracking data and vehicle baseline fill levels to an XML file in the current savegame directory.
+-- Data saved includes harvested amounts grouped by year, field, and crop, as well as last known fill levels per vehicle.
+function YieldTracker:saveYieldData()
+ if not g_currentMission or not g_currentMission.missionInfo.savegameDirectory then
+ self:debugMessage("Save skipped: no savegame directory")
+ return
+ end
+
+ local filePath = g_currentMission.missionInfo.savegameDirectory .. "/" .. self.saveFileName
+ local xmlFile = createXMLFile("YieldDataXML", filePath, "yieldData")
+
+ -- Save yield data per year/field/crop
+ local i = 0
+ for year, fields in pairs(self.yieldData) do
+ local yearKey = string.format("yieldData.year(%d)", i)
+ setXMLInt(xmlFile, yearKey .. "#value", year)
+
+ local j = 0
+ for fieldId, crops in pairs(fields) do
+ local fieldKey = string.format("%s.field(%d)", yearKey, j)
+ setXMLInt(xmlFile, fieldKey .. "#id", fieldId)
+
+ local k = 0
+ for fillTypeIndex, cropData in pairs(crops) do
+ local cropKey = string.format("%s.crop(%d)", fieldKey, k)
+ setXMLInt(xmlFile, cropKey .. "#fillTypeIndex", fillTypeIndex)
+ setXMLString(xmlFile, cropKey .. "#name", cropData.fillTypeTitle)
+ setXMLFloat(xmlFile, cropKey .. "#amount", cropData.amount)
+ k = k + 1
+ end
+ j = j + 1
+ end
+ i = i + 1
+ end
+
+ -- Save prevFillLevels baseline per vehicle
+ local v = 0
+ for vehicleId, level in pairs(self.prevFillLevels or {}) do
+ local vehicleKey = string.format("yieldData.prevFillLevels.vehicle(%d)", v)
+ setXMLInt(xmlFile, vehicleKey .. "#id", vehicleId)
+ setXMLFloat(xmlFile, vehicleKey .. "#level", level)
+ v = v + 1
+ end
+
+
+
+ -- Save transient live tank contributions separately so they can be subtracted from pipe base totals on reload.
+ local lt = 0
+ for vehicleId, contribution in pairs(self.liveTankContributions or {}) do
+ if contribution.amount ~= nil and contribution.amount > 0.001 then
+ local liveKey = string.format("yieldData.liveTankContributions.vehicle(%d)", lt)
+ setXMLString(xmlFile, liveKey .. "#id", tostring(vehicleId))
+ setXMLInt(xmlFile, liveKey .. "#year", contribution.year)
+ setXMLInt(xmlFile, liveKey .. "#fieldId", contribution.fieldID)
+ setXMLInt(xmlFile, liveKey .. "#fillTypeIndex", contribution.fillTypeIndex)
+ setXMLFloat(xmlFile, liveKey .. "#amount", contribution.amount)
+ lt = lt + 1
+ end
+ end
+
+ saveXMLFile(xmlFile)
+ delete(xmlFile)
+
+ self:debugMessage("Yield data saved to XML")
+end
+
+--=======================================================================================================================================================================
diff --git a/FS25_YieldTracker/translations/translation_br.xml b/FS25_YieldTracker/translations/translation_br.xml
index c4ca0e3..d706971 100644
--- a/FS25_YieldTracker/translations/translation_br.xml
+++ b/FS25_YieldTracker/translations/translation_br.xml
@@ -1,40 +1,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FS25_YieldTracker/translations/translation_cs.xml b/FS25_YieldTracker/translations/translation_cs.xml
index 3987760..021c05a 100644
--- a/FS25_YieldTracker/translations/translation_cs.xml
+++ b/FS25_YieldTracker/translations/translation_cs.xml
@@ -1,40 +1,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FS25_YieldTracker/translations/translation_de.xml b/FS25_YieldTracker/translations/translation_de.xml
index fec1c52..076868c 100644
--- a/FS25_YieldTracker/translations/translation_de.xml
+++ b/FS25_YieldTracker/translations/translation_de.xml
@@ -1,40 +1,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FS25_YieldTracker/translations/translation_en.xml b/FS25_YieldTracker/translations/translation_en.xml
index 1dbe65c..46bec2e 100644
--- a/FS25_YieldTracker/translations/translation_en.xml
+++ b/FS25_YieldTracker/translations/translation_en.xml
@@ -1,40 +1,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/FS25_YieldTracker/translations/translation_fr.xml b/FS25_YieldTracker/translations/translation_fr.xml
index bd49fc8..1602b89 100644
--- a/FS25_YieldTracker/translations/translation_fr.xml
+++ b/FS25_YieldTracker/translations/translation_fr.xml
@@ -1,40 +1,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FS25_YieldTracker/translations/translation_pl.xml b/FS25_YieldTracker/translations/translation_pl.xml
index 569caab..5c6686a 100644
--- a/FS25_YieldTracker/translations/translation_pl.xml
+++ b/FS25_YieldTracker/translations/translation_pl.xml
@@ -1,40 +1,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FS25_YieldTracker/translations/translation_pt.xml b/FS25_YieldTracker/translations/translation_pt.xml
index bd762d4..32d1e8b 100644
--- a/FS25_YieldTracker/translations/translation_pt.xml
+++ b/FS25_YieldTracker/translations/translation_pt.xml
@@ -1,40 +1,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+