diff --git a/gamedata/modrules.lua b/gamedata/modrules.lua
index f568fc5ebdf..5a68ccc28e6 100644
--- a/gamedata/modrules.lua
+++ b/gamedata/modrules.lua
@@ -58,7 +58,7 @@ local modrules = {
los = {
losMipLevel = 3, -- Controls the resolution of the LOS calculations. A higher value means lower resolution but increased performance. An increase by one level means half the resolution of the LOS map in both x and y direction. Must be between 0 and 6 inclusive.
airMipLevel = 4, -- Controls the resolution of the LOS vs. aircraft calculations. A higher value means lower resolution but increased performance. An increase by one level means half the resolution of the air-LOS map in both x and y direction. Must be between 0 and 30 inclusive. [1] - jK describe for you what the value means.
- radarMipLevel = 3, -- Controls the resolution of the radar. See description of airMipLevel for details.
+ radarMipLevel = 2, -- Controls the resolution of the radar. See description of airMipLevel for details.
},
},
diff --git a/luarules/callins/synthetic_callins.lua b/luarules/callins/synthetic_callins.lua
index c35dc132f13..9992b0af1b8 100644
--- a/luarules/callins/synthetic_callins.lua
+++ b/luarules/callins/synthetic_callins.lua
@@ -9,15 +9,20 @@
-- event system, in many cases, so code built on top of these foundations needs
-- to keep up with the times and adapt as the gaps fill in from the other side.
--
--- This also produces two types of summary view of a base callin:
--- 1. *Post.* This is a mark-and-sweep pattern to update dirtied IDs.
--- 2. *Total.* This is an accumulator with a hook for custom results.
+-- This also produces a few types of "summary views" over a base callin:
+-- - *Post.* This is a mark-and-sweep pattern to update dirtied IDs.
+-- - *Total.* This is an accumulator with a hook for custom results.
+-- - *Transition.* Tracks state changes, holding a latch and ignoring loops.
--
-- Adding a new callin:
-- 1. Add the callin's envs and subscriptions to syntheticCallins.
--- 2. If producing a summary view, add to syntheticCallinSummaries.
+-- 2. If producing a summary view, add to one of the following sets:
+-- `syntheticCallinSummaries`:
-- This produces both the Post and Total callins and
-- adds the custom GG.Accumulate hook later during install.
+-- `syntheticCallinTransitions`:
+-- This produces only the Post callin and collects state changes
+-- to be raised later only if they do not resolve subframe, eg A->B->A.
-- 3. If the callin tracks more state, add it to syntheticCallinUpdate.
-- 4. Add the callin's implementation (and locals) to the Dispatch section.
-- 5. Add the callin to the handler in the Install section.
@@ -33,26 +38,31 @@ local env = Script.GetSynced() and "synced" or "unsynced"
-- can become unhooked whenever no addon happens to subscribe to their base.
local syntheticCallins = {
shared = {
- MetaUnitAdded = { 'UnitGiven', 'UnitCreated' },
- MetaUnitRemoved = { 'UnitTaken', 'UnitDestroyed' },
+ MetaUnitAdded = { "UnitGiven", "UnitCreated" },
+ MetaUnitRemoved = { "UnitTaken", "UnitDestroyed" },
},
synced = {
- UnitAutoTargetRange = { 'AllowWeaponTarget' },
- UnitBuildStepPost = { 'GameFramePost', 'AllowUnitBuildStep' },
- FeatureBuildStepPost = { 'GameFramePost', 'AllowFeatureBuildStep' },
- UnitBuildStepTotal = { 'GameFramePost', 'AllowUnitBuildStep' },
- FeatureBuildStepTotal = { 'GameFramePost', 'AllowFeatureBuildStep' },
+ UnitAutoTargetRange = { "AllowWeaponTarget" },
+ UnitBuildStepPost = { "GameFramePost", "AllowUnitBuildStep" },
+ FeatureBuildStepPost = { "GameFramePost", "AllowFeatureBuildStep" },
+ UnitBuildStepTotal = { "GameFramePost", "AllowUnitBuildStep" },
+ FeatureBuildStepTotal = { "GameFramePost", "AllowFeatureBuildStep" },
+ UnitIdlePost = { "GameFramePost", "UnitIdle", "UnitCommand", "UnitTaken", "UnitDestroyed" }, -- See modules/unit_idle_states.lua.
},
unsynced = {},
}
local syntheticCallinSummaries = {
- UnitBuildStep = true,
+ UnitBuildStep = true,
FeatureBuildStep = true,
}
+local syntheticCallinTransitions = {
+ UnitIdle = true,
+}
+
-- The engine does not know these names, so `Script.UpdateCallIn` is a no-op.
-- We have to handle dropping tracked state, etc., during updates on our own.
-- Update handlers receive the gadgetHandler to read their subscriber lists.
@@ -67,13 +77,13 @@ local callinNames = table.keys(syntheticCallinHold)
local callinHoldSummary = {}
for name, callinHolds in pairs(syntheticCallinHold) do
for _, callin in ipairs(callinHolds) do
- local listName = callin .. 'List'
+ local listName = callin .. "List"
local holders = callinHoldSummary[listName]
if not holders then
holders = {}
callinHoldSummary[listName] = holders
end
- holders[#holders + 1] = name .. 'List'
+ holders[#holders + 1] = name .. "List"
end
end
@@ -121,6 +131,9 @@ end
---@class SummaryActive
---@field [1] true? whether accumulating totals
+---Sticky-state per marked ID, kept while active.
+---@alias SummaryLatched table
+
local function createSummary(callinName)
if not syntheticCallinSummaries[callinName] then
return
@@ -134,7 +147,7 @@ local function createSummary(callinName)
marks[callinName] = { marked = marked, list = list, count = count, totals = totals, active = active, stop = stop }
- accumulate['Accumulate' .. callinName] = function(id, amount)
+ accumulate["Accumulate" .. callinName] = function(id, amount)
-- Call sites must not accumulate when not subscribed.
local n = count[1]
if not n then
@@ -154,8 +167,8 @@ local function createSummary(callinName)
end
-- Both summary views share updates so must handle updating together.
- local postList = callinName .. 'PostList'
- local totalList = callinName .. 'TotalList'
+ local postList = callinName .. "PostList"
+ local totalList = callinName .. "TotalList"
local function update(gh)
if #gh[totalList] > 0 then
active[1] = true
@@ -171,8 +184,36 @@ local function createSummary(callinName)
stop()
end
end
- syntheticCallinUpdate[callinName .. 'Post'] = update
- syntheticCallinUpdate[callinName .. 'Total'] = update
+ syntheticCallinUpdate[callinName .. "Post"] = update
+ syntheticCallinUpdate[callinName .. "Total"] = update
+end
+
+local function createTransition(callinName)
+ if not syntheticCallinTransitions[callinName] then
+ return
+ end
+
+ ---@type SummaryMarked, SummaryList, SummaryCount
+ local marked, list, count = {}, {}, table.new(1, 0)
+ ---@type SummaryLatched
+ local latched = {}
+ local stop = makeStopMarking(marked, list, count)
+
+ marks[callinName] = { marked = marked, list = list, count = count, latched = latched, stop = stop }
+
+ local postList = callinName .. "PostList"
+ syntheticCallinUpdate[callinName .. "Post"] = function(gh)
+ if #gh[postList] > 0 then
+ count[1] = count[1] or 0
+ else
+ stop()
+ -- After the last subscriber drops, any late-subscribers cannot trust
+ -- the latched states anymore, so the states also must be dropped.
+ for id in pairs(latched) do
+ latched[id] = nil
+ end
+ end
+ end
end
---A summary's marking state. Callins from non-matching envs get empty tables.
@@ -185,8 +226,8 @@ end
local function getMarks(baseName)
local mark = marks[baseName]
if not mark then
- if not syntheticCallinSummaries[baseName] then
- error('synthetic_callins: no such summary: ' .. tostring(baseName))
+ if not syntheticCallinSummaries[baseName] and not syntheticCallinTransitions[baseName] then
+ error("synthetic_callins: no such summary: " .. tostring(baseName))
end
return {}, {}, {}, {}, {}
end
@@ -198,11 +239,16 @@ local function getMarksUnsafe(baseName)
return mark.marked, mark.list, mark.count, mark.totals, mark.active
end
+---@return SummaryLatched latched
+local function getLatchUnsafe(baseName)
+ return marks[baseName].latched
+end
+
local function createSweep(callinName)
local marked, list, countBox, totals, activeBox = getMarksUnsafe(callinName)
local values = {}
- local postName, totalName = callinName .. 'Post', callinName .. 'Total'
- local postListName, totalListName = postName .. 'List', totalName .. 'List'
+ local postName, totalName = callinName .. "Post", callinName .. "Total"
+ local postListName, totalListName = postName .. "List", totalName .. "List"
return function(handler)
local count = countBox[1]
@@ -248,11 +294,63 @@ local function createSweep(callinName)
end
end
+---We distrust the engine's `:UnitIdle` callin so read the unit queue directly.
+---See unit_idle_states.lua for detail on unit behaviors, namely, "idle tasks".
+local function createUnitIdleSweep()
+ local marked, list, countBox = getMarksUnsafe("UnitIdle")
+ local latched = getLatchUnsafe("UnitIdle")
+ local transitions, states = {}, {}
+
+ local VFSMODE = Spring.IsDevLuaEnabled() and VFS.RAW_FIRST or VFS.ZIP_ONLY
+ local isIdle = VFS.Include("modules/unit_idle_states.lua", nil, VFSMODE).IsIdle
+
+ local spGetUnitDefID = Spring.GetUnitDefID
+ local spGetUnitIsDead = Spring.GetUnitIsDead
+
+ return function(handler)
+ local count = countBox[1]
+ if not count or count == 0 then
+ return
+ end
+ countBox[1] = 0
+
+ -- Clear marks first so subscribers that throw do not leave any marks.
+ -- Gathering the batch up front also lets subscribers re-mark safely.
+ local n = 0
+ for i = 1, count do
+ local unitID = list[i]
+ marked[unitID] = nil
+
+ -- Units can be finalized between mark and sweep. Ignore dying units.
+ local unitDefID = spGetUnitDefID(unitID)
+ if unitDefID and spGetUnitIsDead(unitID) == false then
+ local idle = isIdle(unitID, unitDefID)
+ if idle ~= (latched[unitID] == true) then
+ latched[unitID] = idle or nil
+ n = n + 1
+ transitions[n] = unitID
+ states[n] = idle
+ end
+ else
+ latched[unitID] = nil
+ end
+ end
+
+ local postList = handler.UnitIdlePostList
+ for i = 1, n do
+ local unitID, idled = transitions[i], states[i]
+ for _, g in ipairs(postList) do
+ g:UnitIdlePost(unitID, idled)
+ end
+ end
+ end
+end
+
--------------------------------------------------------------------------------
-- Dispatch ------------------------------------------------------------------
--
-- Callin implementations attach to the gadgetHandler in the Install section.
---
+--
-- - UnitAutoTargetRange has its base implementation in gadgets.lua, instead.
local callins = {}
@@ -274,11 +372,14 @@ end
-- Synced environment
if Script.GetSynced() then
- createSummary('UnitBuildStep')
- callins.SweepUnitBuildStep = createSweep('UnitBuildStep')
+ createSummary("UnitBuildStep")
+ callins.SweepUnitBuildStep = createSweep("UnitBuildStep")
+
+ createSummary("FeatureBuildStep")
+ callins.SweepFeatureBuildStep = createSweep("FeatureBuildStep")
- createSummary('FeatureBuildStep')
- callins.SweepFeatureBuildStep = createSweep('FeatureBuildStep')
+ createTransition("UnitIdle")
+ callins.SweepUnitIdle = createUnitIdleSweep()
end
-- Unsynced environment
@@ -310,7 +411,7 @@ local function install(handler)
end
end
- handler.MetaUnitAdded = callins.MetaUnitAdded
+ handler.MetaUnitAdded = callins.MetaUnitAdded
handler.MetaUnitRemoved = callins.MetaUnitRemoved
-- Wrap multi-env dispatchers for single-env synthetic callins at install
@@ -320,11 +421,15 @@ local function install(handler)
local gameFramePost = handler.GameFramePost
local sweepUnitBuildStep = callins.SweepUnitBuildStep
local sweepFeatureBuildStep = callins.SweepFeatureBuildStep
+ local sweepUnitIdle = callins.SweepUnitIdle
+
function handler:GameFramePost(frameNum)
tracy.ZoneBeginN("G:GameFramePostSummary")
sweepUnitBuildStep(self)
sweepFeatureBuildStep(self)
+ sweepUnitIdle(self)
tracy.ZoneEnd()
+
return gameFramePost(self, frameNum)
end
@@ -344,8 +449,8 @@ end
---Synthetic callin registry and dispatch for gadgets.lua.
---@class SyntheticCallinsAPI
local synthetic = {
- install = install,
- getMarks = getMarks,
+ install = install,
+ getMarks = getMarks,
callinNames = callinNames,
}
diff --git a/luarules/gadgets.lua b/luarules/gadgets.lua
index 941d10c00c1..51365cb3703 100644
--- a/luarules/gadgets.lua
+++ b/luarules/gadgets.lua
@@ -461,10 +461,23 @@ end
-- Synthetic callins
--
-- The game injects some of its own callins into the engine-driven event system:
-local synthetic = VFS.Include(SCRIPT_DIR .. 'callins/synthetic_callins.lua', nil, VFSMODE) ---@type SyntheticCallinsAPI
+local synthetic = VFS.Include(SCRIPT_DIR .. "callins/synthetic_callins.lua", nil, VFSMODE) ---@type SyntheticCallinsAPI
-local unitStepMarked, unitStepList, unitStepCount, unitStepTotals, unitStepActive = synthetic.getMarks('UnitBuildStep')
-local featureStepMarked, featureStepList, featureStepCount, featureStepTotals, featureStepActive = synthetic.getMarks('FeatureBuildStep')
+-- stylua: ignore start
+local unitStepMarked, unitStepList, unitStepCount, unitStepTotals, unitStepActive = synthetic.getMarks("UnitBuildStep")
+local featureStepMarked, featureStepList, featureStepCount, featureStepTotals, featureStepActive = synthetic.getMarks("FeatureBuildStep")
+local unitIdleMarked, unitIdleList, unitIdleCount = synthetic.getMarks("UnitIdle")
+-- stylua: ignore end
+
+local function markIdle(unitID)
+ local idleCount = unitIdleCount[1]
+ if idleCount and not unitIdleMarked[unitID] then
+ unitIdleMarked[unitID] = true
+ idleCount = idleCount + 1
+ unitIdleCount[1] = idleCount
+ unitIdleList[idleCount] = unitID
+ end
+end
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
@@ -2147,10 +2160,10 @@ end
function gadgetHandler:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerDefID, attackerTeam, weaponDefID)
tracy.ZoneBeginN("G:UnitDestroyed")
self:MetaUnitRemoved(unitID, unitDefID, unitTeam)
-
for _, g in ipairs(self.UnitDestroyedList) do
g:UnitDestroyed(unitID, unitDefID, unitTeam, attackerID, attackerDefID, attackerTeam, weaponDefID)
end
+ markIdle(unitID)
tracy.ZoneEnd()
return
end
@@ -2176,6 +2189,7 @@ function gadgetHandler:UnitIdle(unitID, unitDefID, unitTeam)
for _, g in ipairs(self.UnitIdleList) do
g:UnitIdle(unitID, unitDefID, unitTeam)
end
+ markIdle(unitID)
tracy.ZoneEnd()
return
end
@@ -2267,10 +2281,10 @@ end
function gadgetHandler:UnitTaken(unitID, unitDefID, unitTeam, newTeam)
self:MetaUnitRemoved(unitID, unitDefID, unitTeam)
-
for _, g in ipairs(self.UnitTakenList) do
g:UnitTaken(unitID, unitDefID, unitTeam, newTeam)
end
+ markIdle(unitID)
return
end
@@ -2299,6 +2313,7 @@ function gadgetHandler:UnitCommand(
for _, g in ipairs(self.UnitCommandList) do
g:UnitCommand(unitID, unitDefID, unitTeam, cmdId, cmdParams, cmdOpts, cmdTag, playerID, fromSynced, fromLua)
end
+ markIdle(unitID)
tracy.ZoneEnd()
return
end
diff --git a/luarules/gadgets/ai_ruins.lua b/luarules/gadgets/ai_ruins.lua
index d5ad5ace0c5..e9333ad0375 100644
--- a/luarules/gadgets/ai_ruins.lua
+++ b/luarules/gadgets/ai_ruins.lua
@@ -254,6 +254,12 @@ function getNearestBlocker(x, z)
return math.sqrt(lowestDist)
end
+-- CreateUnit does not snap; Pos2BuildPos uses even vs odd grid from footprint parity.
+local function createSnappedUnit(defID, x, y, z, facing, teamID)
+ x, y, z = Spring.Pos2BuildPos(defID, x, y, z, facing)
+ return Spring.CreateUnit(defID, x, y, z, facing, teamID)
+end
+
local function spawnRuin(ruin, posx, posy, posz, blueprintTierLevel)
local swapXandY, flipX, flipZ, rotation = randomlyRotateBlueprint()
local mirrored, mirroredDirection, xOffset, zOffset
@@ -285,16 +291,11 @@ local function spawnRuin(ruin, posx, posy, posz, blueprintTierLevel)
local nonscavname = string.gsub(name, "_scav", "")
local r = math.random(1, 100)
if r < 40 and UnitDefNames[nonscavname] then
- local posy =
- Spring.GetGroundHeight(posx + (xOffset * flipX * mirrorX), posz + (zOffset * flipZ * mirrorZ))
- local unit = Spring.CreateUnit(
- UnitDefNames[nonscavname].id,
- posx + (xOffset * flipX * mirrorX),
- posy,
- posz + (zOffset * flipZ * mirrorZ),
- (building.direction + rotation + mirrorRotation) % 4,
- GaiaTeamID
- )
+ local facing = (building.direction + rotation + mirrorRotation) % 4
+ local bx = posx + (xOffset * flipX * mirrorX)
+ local bz = posz + (zOffset * flipZ * mirrorZ)
+ local posy = Spring.GetGroundHeight(bx, bz)
+ local unit = createSnappedUnit(UnitDefNames[nonscavname].id, bx, posy, bz, facing, GaiaTeamID)
if unit then
local radarRange = UnitDefs[building.unitDefID].radarDistance
local canMove = UnitDefs[building.unitDefID].canMove
@@ -371,7 +372,8 @@ local function SpawnMexes(mexSpots)
if canBuildHere then
local mex = mexesList[math.random(1, #mexesList)]
- local unit = Spring.CreateUnit(UnitDefNames[mex].id, posx, posy, posz, math.random(0, 3), GaiaTeamID)
+ local facing = math.random(0, 3)
+ local unit = createSnappedUnit(UnitDefNames[mex].id, posx, posy, posz, facing, GaiaTeamID)
if unit then
Spring.SetUnitNeutral(unit, true)
Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0)
@@ -398,6 +400,11 @@ local function SpawnGeos(geoSpots)
geosList = seaGeosList
end
+ local geo = geosList[math.random(1, #geosList)]
+ local defID = UnitDefNames[geo].id
+ local facing = math.random(0, 3)
+ posx, posy, posz = Spring.Pos2BuildPos(defID, posx, posy, posz, facing)
+
local radius = 32
local canBuildHere = positionCheckLibrary.VisibilityCheckEnemy(
posx,
@@ -422,8 +429,7 @@ local function SpawnGeos(geoSpots)
end
if canBuildHere then
- local geo = geosList[math.random(1, #geosList)]
- local unit = Spring.CreateUnit(UnitDefNames[geo].id, posx, posy, posz, math.random(0, 3), GaiaTeamID)
+ local unit = createSnappedUnit(defID, posx, posy, posz, facing, GaiaTeamID)
if unit then
Spring.SetUnitNeutral(unit, true)
Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0)
@@ -482,14 +488,9 @@ local function SpawnMexGeoRandomStructures()
if canBuildHere then
local defence = defencesList[math.random(1, #defencesList)]
- local unit = Spring.CreateUnit(
- UnitDefNames[defence].id,
- posx2,
- posy2,
- posz2,
- math.random(0, 3),
- GaiaTeamID
- )
+ local facing = math.random(0, 3)
+ local unit =
+ createSnappedUnit(UnitDefNames[defence].id, posx2, posy2, posz2, facing, GaiaTeamID)
if unit then
Spring.SetUnitNeutral(unit, true)
Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0)
@@ -547,14 +548,9 @@ local function SpawnMexGeoRandomStructures()
if canBuildHere then
local defence = defencesList[math.random(1, #defencesList)]
- local unit = Spring.CreateUnit(
- UnitDefNames[defence].id,
- posx2,
- posy2,
- posz2,
- math.random(0, 3),
- GaiaTeamID
- )
+ local facing = math.random(0, 3)
+ local unit =
+ createSnappedUnit(UnitDefNames[defence].id, posx2, posy2, posz2, facing, GaiaTeamID)
if unit then
Spring.SetUnitNeutral(unit, true)
Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0)
@@ -609,8 +605,8 @@ local function SpawnRandomStructures()
if canBuildHere then
local defence = defencesList[math.random(1, #defencesList)]
- local unit =
- Spring.CreateUnit(UnitDefNames[defence].id, posx, posy, posz, math.random(0, 3), GaiaTeamID)
+ local facing = math.random(0, 3)
+ local unit = createSnappedUnit(UnitDefNames[defence].id, posx, posy, posz, facing, GaiaTeamID)
if unit then
Spring.SetUnitNeutral(unit, true)
Spring.GiveOrderToUnit(unit, CMD.FIRE_STATE, { 1 }, 0)
diff --git a/luarules/gadgets/cmd_dev_helpers.lua b/luarules/gadgets/cmd_dev_helpers.lua
index 458dc87d21e..b94d1dc42cc 100644
--- a/luarules/gadgets/cmd_dev_helpers.lua
+++ b/luarules/gadgets/cmd_dev_helpers.lua
@@ -2835,7 +2835,7 @@ else -- UNSYNCED
end
-- give units
- local exlusions = {
+ local exclusions = {
meteor = true,
raptor_hive = true,
nuketest = true,
@@ -2845,18 +2845,18 @@ else -- UNSYNCED
scavtacnukespawner = true,
scavempspawner = true,
}
- local newExlusions = {}
- for k, v in pairs(exlusions) do
- newExlusions[k] = true
- newExlusions[k .. "_scav"] = true
+ local newExclusions = {}
+ for k, v in pairs(exclusions) do
+ newExclusions[k] = true
+ newExclusions[k .. "_scav"] = true
end
- exlusions = newExlusions
- newExlusions = nil
+ exclusions = newExclusions
+ newExclusions = nil
local giveUnits = {}
for _, ud in pairs(UnitDefs) do
local give = true
for _, Condition in ipairs(Accept) do
- if not Condition(ud) or exlusions[ud.name] then
+ if not Condition(ud) or exclusions[ud.name] then
give = false
break
end
diff --git a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl
index b7c3fe0aca5..a34269e81e3 100644
--- a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl
+++ b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl
@@ -15,11 +15,11 @@
layout (location = 0) in vec4 cubeVertex; // unit cube corner: x,z in [-0.5, 0.5], y in [0, 1]
uniform vec4 radarcenter_range; // cube grid center x, emitter height, cube grid center z, effective range (elmo)
-uniform vec4 gridParams; // radar cell size (elmo), coverage texels per side, cube spacing, cube cells per side
+uniform vec4 gridParams; // radar cell size (elmo), coverage texels per side, cube spacing (elmo), cubes per radar cell edge
uniform vec4 lookupParams; // emitter cell x, emitter cell y, radius in cells, unused
uniform vec4 shapeParams; // cube width, cube height (0 = flat tile), sink below ground, lift of the top above ground
uniform vec4 animParams; // time (s), seconds since the preview appeared, lod blend (0 = fine grid, 1 = double spacing), conform (1 = top follows terrain)
-uniform vec4 windowParams; // first cell x, first cell z, cells per row, cell stride (1 or 2)
+uniform vec4 windowParams; // first cube index x, first cube index z, cubes per row, index stride (1 or 2)
uniform sampler2D heightmapTex;
uniform sampler2D coverageTex;
@@ -45,6 +45,8 @@ const float pulseFreq = 2.0 * PI / float(PULSE_SPACING);
const float pulseSpeed = 2.0 * PI * float(PULSE_SPEED) / float(PULSE_SPACING);
const float pulsePower = float(PULSE_POWER);
const float pulseStrength = float(PULSE_STRENGTH);
+const float edgeStrength = float(EDGE_STRENGTH); // glow of cubes at the coverage boundary (0 = off)
+const float rimStrength = float(RIM_STRENGTH); // glow of the outermost ring of cubes (0 = off)
const float tileMaxTilt = tan(radians(float(TILE_MAX_TILT))); // slope (rise/run) of the steepest tile tilt
const float tileCliffStart = tan(radians(float(TILE_CLIFF_START))); // terrain slope where tiles start flattening
const float tileCliffEnd = tan(radians(float(TILE_CLIFF_END))); // terrain slope where tiles are flat again
@@ -64,6 +66,8 @@ void cullInstance() {
void main() {
int stride = int(windowParams.w);
int rowLength = int(windowParams.z);
+ // absolute cube grid index: the spacing divides the radar cell size, so with center = (index + 0.5) * spacing
+ // every radar cell holds an NxN block of cubes centered inside it
ivec2 cell = ivec2(windowParams.xy) + ivec2(gl_InstanceID % rowLength, gl_InstanceID / rowLength) * stride;
// optional far LOD: cells with an odd index shrink away, the remaining ones grow to keep the visual density
@@ -72,9 +76,8 @@ void main() {
float lodScale = 1.0 - isFine * lodBlend;
float range = radarcenter_range.w;
- float halfCells = 0.5 * (gridParams.w - 1.0);
- vec2 fromCenter = (vec2(cell) - halfCells) * gridParams.z;
- vec2 cellXZ = radarcenter_range.xz + fromCenter;
+ vec2 cellXZ = (vec2(cell) + 0.5) * gridParams.z;
+ vec2 fromCenter = cellXZ - radarcenter_range.xz;
float dist = length(fromCenter);
// which radar cell is this cube in, relative to the emitter's cell
@@ -116,11 +119,11 @@ void main() {
// the main animation: rings travelling outward from the radar
float ring = pow(0.5 + 0.5 * sin(dist * pulseFreq - time * pulseSpeed), pulsePower);
- // highlight cells at the coverage boundary (an uncovered radar cell next door) and the outer rim
+ // highlight cells at the coverage boundary (an uncovered radar cell next door) and the outer rim (EDGE_STRENGTH, RIM_STRENGTH)
float edge = coverageState.g;
float rim = smoothstep(range - 1.5 * radarCell, range - 0.25 * radarCell, dist);
- float glow = clamp(sweep + beam + edge * 0.5 + rim * 0.35 + ring * 0.6 * pulseStrength + bump * 0.7, 0.0, 1.5);
+ float glow = clamp(sweep + beam + edge * edgeStrength + rim * rimStrength + ring * 0.6 * pulseStrength + bump * 0.7, 0.0, 1.5);
float height = shapeParams.y * (0.35 + 0.65 * coverage) * spawn
* (1.0 + 0.5 * sweep + pulseStrength * ring + 0.6 * bump);
diff --git a/luaui/Widgets/gui_buildsquare_gl4.lua b/luaui/Widgets/gui_buildsquare_gl4.lua
index f954241e304..e9a4a5b6f05 100644
--- a/luaui/Widgets/gui_buildsquare_gl4.lua
+++ b/luaui/Widgets/gui_buildsquare_gl4.lua
@@ -61,9 +61,9 @@ local spGetUnitCommands = Spring.GetUnitCommands
local spGetMyPlayerID = Spring.GetLocalPlayerID
local spGetMouseState = Spring.GetMouseState
local spTraceScreenRay = Spring.TraceScreenRay
-local spWorldToScreenCoords = Spring.WorldToScreenCoords
local spGetBuildFacing = Spring.GetBuildFacing
local spTestBuildOrder = Spring.TestBuildOrder
+local spGetActiveCommand = Spring.GetActiveCommand
local spGetTimer = Spring.GetTimer
local spDiffTimers = Spring.DiffTimers
local spGetDrawFrame = Spring.GetDrawFrame
@@ -93,22 +93,25 @@ local CORNER_RADIUS = 0.22
local STYLE_OPEN_YARDMAP_CELLS_AS_EXTENDED = true
local EXTENDED_CELLS = 0
local COMBINE_FOUR_CELLS = true
+local COMBINE_VALID_FOOTPRINT_CELLS = true -- merge same-styled cells of a placeable footprint into blocks with a single outline
+local FOLLOW_EXTRACTOR_SNAP = true -- draw the preview at the spot the extractor snap widget targets instead of at the cursor
local EXTENDED_ALPHA_NEAR = 0.1
local EXTENDED_ALPHA_FAR = 0.05
local FOOTPRINT_BOUNDARY_ENABLED = true
local FOOTPRINT_BOUNDARY_WIDTH = 0.22
-local SHOW_INVALID_FOOTPRINT_BOUNDARY = true
+local SHOW_INVALID_FOOTPRINT_BOUNDARY = false
local EXTENDED_STATUS_UPDATE_INTERVAL = 0.20
local TARGET_STATUS_CHECKS_PER_GAME_FRAME = 64
local TARGET_STATUS_CELLS_PER_GAME_FRAME = 512
local MAX_STATUS_CHECK_PERIOD = 10
local SIMPLIFIED_FOOTPRINTS_ENABLED = true
local SIMPLIFIED_OUTLINE_SCALE = 0.5
-local SIMPLIFIED_CORNER_RADIUS_SCALE = 0.5
+local SIMPLIFIED_CORNER_RADIUS_SCALE = 0.5 -- corner radius of simplified quads and merged blocks, relative to CORNER_RADIUS, at the reference footprint size
+local SIMPLIFIED_SIZE_REFERENCE_CELLS = 6 -- footprint size (cells) at which the simplified corner radius and outline scales apply unchanged
+local SIMPLIFIED_SIZE_FALLOFF = 0.55 -- corner radius and outline width vs footprint size: 0 = proportional, 1 = same for every size, in between = diminishing growth
local SIMPLIFIED_BUILDING_THRESHOLD = 384
local SIMPLIFIED_CELL_THRESHOLD = 8192
local MINIMUM_SCREEN_DIAMETER = 3.0
-local MINIMUM_DETAILED_CELL_DIAMETER = 2.0
local SIMPLIFIED_MINIMAP_ENABLED = true
local MAX_MINIMAP_BUILDINGS = 16384
local MAX_BATCH_CELLS = 262144
@@ -175,6 +178,15 @@ local extendedStatuses = {}
local sourceCellStatuses = {}
local sourceOpenYardmapFlags = {}
local sourceTerrainBlockedFlags = {}
+local mergeCellKeys = {}
+local mergeStyleColors = {}
+local mergeStyleOutlineColors = {}
+local mergeStyleAlphas = {}
+local mergeStyleOutlineAlphas = {}
+local mergeRectData = {}
+local mergeOpenRects = {}
+local styleColorIds = {}
+local styleColorIdCount = 0
local candidateBuildHeights = {}
local candidateBuildHeightGenerations = {}
local candidateBuildHeightGeneration = 0
@@ -194,7 +206,7 @@ local statusChecksEnabled = true
local statusCheckTargetPhase = 0
local orderedPreviewCaches = {}
local pregameStatuses = {}
-local pregameStatusCount = 0
+local snapStatuses = {}
local queuedBuildFootprints = {}
local queuedBuildFootprintCount = 0
local queuedBuildFootprintsGameFrame = -1
@@ -417,12 +429,14 @@ local function collectPreview(
zsize,
extendedCells,
cellScale,
- simplified
+ simplified,
+ merged
)
appendCollectedPreview(renderCache)
if
renderCache.batchNumCells ~= numCells
or renderCache.batchSimplified ~= simplified
+ or renderCache.batchMerged ~= merged
or renderCache.batchOriginX ~= originX
or renderCache.batchOriginZ ~= originZ
or renderCache.batchCellScale ~= cellScale
@@ -436,6 +450,7 @@ local function collectPreview(
renderCache.batchExtendedCells = extendedCells
renderCache.batchCellScale = cellScale
renderCache.batchSimplified = simplified
+ renderCache.batchMerged = merged
end
end
@@ -487,8 +502,6 @@ local function getCellGeometry(xsize, zsize, extendedCells, cellScale)
dataIndex = dataIndex + 1
geometryData[dataIndex] = zi * SQUARE_SIZE * cellScale
dataIndex = dataIndex + 1
- geometryData[dataIndex] = cellScale > 1 and -cellScale or 0.0
- dataIndex = dataIndex + 1
geometryData[dataIndex] = footprintEdges
end
end
@@ -529,22 +542,6 @@ local function getEffectiveExtendedCells()
return effectiveExtendedCells, buildSquareGameFrame, statusCheckPeriod, drawSquareCount, simplifiedFootprintMode
end
-local function needsDistanceSimplification(footprint, x, z)
- local minX = x - footprint.halfXsize * SQUARE_SIZE
- local maxX = minX + footprint.xsize * SQUARE_SIZE
- local minZ = z - footprint.halfZsize * SQUARE_SIZE
- local maxZ = minZ + footprint.zsize * SQUARE_SIZE
- local screenLeftX = spWorldToScreenCoords(minX, spGetGroundHeight(minX, z), z)
- local screenRightX = spWorldToScreenCoords(maxX, spGetGroundHeight(maxX, z), z)
- local _, screenTopY = spWorldToScreenCoords(x, spGetGroundHeight(x, minZ), minZ)
- local _, screenBottomY = spWorldToScreenCoords(x, spGetGroundHeight(x, maxZ), maxZ)
- if not screenLeftX or not screenRightX or not screenTopY or not screenBottomY then
- return false
- end
- return mathAbs(screenRightX - screenLeftX) / footprint.xsize < MINIMUM_DETAILED_CELL_DIAMETER
- or mathAbs(screenBottomY - screenTopY) / footprint.zsize < MINIMUM_DETAILED_CELL_DIAMETER
-end
-
local function getPredictedCellStatus(unitDef, worldX, worldZ, buildHeight)
if worldX < 0 or worldZ < 0 or worldX >= MAP_SIZE_X or worldZ >= MAP_SIZE_Z then
return STATUS_BLOCKED, STATUS_OPEN
@@ -697,6 +694,7 @@ local function getPreviewRenderCache(unitDefID, x, z, facing, sequenceIndex)
cache.extendedStatusCount = 0
cache.colorRevision = (cache.colorRevision or 0) + 1
cache.batchNumCells = nil
+ cache.mergedRectCount = nil
addPreviewRenderCache(cache)
orderedPreviewCaches[sequenceIndex] = cache
return cache
@@ -712,10 +710,11 @@ local vsSrc = [[
#extension GL_ARB_shading_language_420pack: require
layout (location = 0) in vec2 a_cornerPos;
-layout (location = 1) in vec4 a_cellData;
+layout (location = 1) in vec4 a_cellData; // x, z, quad width, quad height (world units)
layout (location = 2) in vec4 a_color;
layout (location = 3) in vec4 a_outlineColor;
-layout (location = 4) in float a_floatOnWater;
+layout (location = 4) in vec4 a_cellExtra; // floatOnWater, mode (0 cell, 1 simplified, 2 merged block), external edge mask, packed footprint data
+layout (location = 5) in vec2 a_footprintSize; // footprint size in cells; merged blocks measure corner radius and outline relative to it
//__ENGINEUNIFORMBUFFERDEFS__
@@ -724,7 +723,11 @@ out vec4 v_outlineColor;
flat out float v_footprintEdges;
flat out float v_footprintValid;
flat out float v_queuedFootprintConflict;
-flat out float v_simplified;
+flat out float v_mode;
+flat out float v_externalEdges;
+flat out vec2 v_quadUnits;
+flat out vec2 v_cellToUnit;
+flat out float v_simplifiedSizeScale;
out vec2 v_cellUV;
uniform sampler2D heightmapTex;
@@ -732,6 +735,8 @@ uniform float heightOffset;
uniform float waterLevel;
uniform float cellInset;
uniform float cellSize;
+uniform float simplifiedReferenceCells;
+uniform float simplifiedSizeFalloff;
uniform float minimumScreenDiameter;
uniform int isMiniMap;
uniform int rotationMiniMap;
@@ -745,28 +750,60 @@ vec2 heightmapUVatWorldPos(vec2 worldpos) {
}
void main() {
- float simplified = step(0.5, a_cellData.z);
- float regularCellScale = max(1.0, -a_cellData.z);
- vec2 quadSize = mix(vec2(cellSize * regularCellScale), a_cellData.zw, simplified);
+ float mode = a_cellExtra.y;
+ bool simplified = mode > 0.5 && mode < 1.5;
+ bool merged = mode > 1.5;
+ vec2 quadSize = a_cellData.zw;
vec2 cellUV = a_cornerPos / cellSize;
- vec2 insetCorner = cellUV * (quadSize - 2.0 * cellInset) + vec2(cellInset);
+ // Merged blocks are only inset on edges that are not shared with a same-styled neighbour block.
+ vec2 insetMin = vec2(cellInset);
+ vec2 insetMax = vec2(cellInset);
+ if (merged) {
+ float externalEdges = a_cellExtra.z;
+ insetMin.x *= mod(floor(externalEdges), 2.0);
+ insetMax.x *= mod(floor(externalEdges / 2.0), 2.0);
+ insetMin.y *= mod(floor(externalEdges / 4.0), 2.0);
+ insetMax.y *= mod(floor(externalEdges / 8.0), 2.0);
+ }
+ vec2 insetQuadSize = quadSize - insetMin - insetMax;
+ vec2 insetCorner = insetMin + cellUV * insetQuadSize;
float wx = a_cellData.x + insetCorner.x;
float wz = a_cellData.y + insetCorner.y;
v_color = a_color;
v_outlineColor = a_outlineColor;
- float packedFootprintData = mix(a_cellData.w, 0.0, simplified);
+ float packedFootprintData = a_cellExtra.w;
v_footprintEdges = mod(packedFootprintData, 16.0);
v_footprintValid = step(15.5, mod(packedFootprintData, 32.0));
v_queuedFootprintConflict = step(31.5, packedFootprintData);
- v_simplified = simplified;
+ v_mode = mode;
+ v_externalEdges = a_cellExtra.z;
+ // Merged blocks measure distances relative to the inset footprint, exactly like the simplified quad of the
+ // whole footprint would, so a fully placeable footprint looks identical to the simplified rendering.
+ vec2 quadUnits = vec2(1.0);
+ vec2 cellToUnit = vec2(1.0);
+ float simplifiedSizeScale = 1.0;
+ if (simplified || merged) {
+ vec2 footprintInsetSize = max(a_footprintSize * cellSize - 2.0 * cellInset, vec2(0.001));
+ // Corner radius and outline width grow with the footprint but with diminishing returns: unchanged at the
+ // reference size, scaled by (reference / footprint) ^ falloff elsewhere (falloff 0 = proportional, 1 = constant).
+ float referenceInsetSize = max(simplifiedReferenceCells * cellSize - 2.0 * cellInset, 0.001);
+ simplifiedSizeScale = pow(referenceInsetSize / min(footprintInsetSize.x, footprintInsetSize.y), simplifiedSizeFalloff);
+ if (merged) {
+ quadUnits = insetQuadSize / footprintInsetSize;
+ cellToUnit = (cellSize - 2.0 * cellInset) / footprintInsetSize;
+ }
+ }
+ v_quadUnits = quadUnits;
+ v_cellToUnit = cellToUnit;
+ v_simplifiedSizeScale = simplifiedSizeScale;
v_cellUV = cellUV;
if (isMiniMap == 0) {
vec2 uvhm = heightmapUVatWorldPos(vec2(wx, wz));
float wy = textureLod(heightmapTex, uvhm, 0.0).x;
- wy = mix(wy, max(wy, waterLevel), a_floatOnWater) + heightOffset;
+ wy = mix(wy, max(wy, waterLevel), a_cellExtra.x) + heightOffset;
vec4 clipPosition = cameraViewProj * vec4(wx, wy, wz, 1.0);
- if (simplified > 0.5) {
+ if (simplified) {
vec2 centerWorldPos = a_cellData.xy + quadSize * 0.5;
vec4 centerClipPosition = cameraViewProj * vec4(centerWorldPos.x, wy, centerWorldPos.y, 1.0);
vec2 centerNdcPosition = centerClipPosition.xy / centerClipPosition.w;
@@ -805,23 +842,84 @@ in vec4 v_outlineColor;
flat in float v_footprintEdges;
flat in float v_footprintValid;
flat in float v_queuedFootprintConflict;
-flat in float v_simplified;
+flat in float v_mode;
+flat in float v_externalEdges;
+flat in vec2 v_quadUnits;
+flat in vec2 v_cellToUnit;
+flat in float v_simplifiedSizeScale;
in vec2 v_cellUV;
out vec4 fragColor;
uniform float simplifiedOutlineScale;
uniform float simplifiedCornerRadiusScale;
uniform float cornerRadius;
+uniform float cellInset;
+uniform float cellSize;
uniform float footprintBoundaryEnabled;
uniform float footprintBoundaryWidth;
uniform float showInvalidFootprintBoundary;
uniform vec4 invalidFootprintBoundaryColor;
void main() {
- float scaledCornerRadius = cornerRadius * mix(1.0, simplifiedCornerRadiusScale, v_simplified);
- float outlineWidth = 0.045 * mix(1.0, simplifiedOutlineScale, v_simplified);
- vec2 cornerDistance = abs(v_cellUV - vec2(0.5)) - vec2(0.5 - scaledCornerRadius);
- float distanceToEdge = length(max(cornerDistance, 0.0)) - scaledCornerRadius;
+ bool merged = v_mode > 1.5;
+ // Simplified quads and merged blocks share the same corner radius and outline width (relative to the footprint).
+ float simplifiedMix = (v_mode > 0.5) ? 1.0 : 0.0;
+ float scaledCornerRadius = min(
+ cornerRadius * mix(1.0, simplifiedCornerRadiusScale * v_simplifiedSizeScale, simplifiedMix),
+ 0.5
+ );
+ float outlineWidth = 0.045 * mix(1.0, simplifiedOutlineScale * v_simplifiedSizeScale, simplifiedMix);
+ // Distances are measured in inset-quad units for cells and simplified quads (the quad is one unit) and in
+ // inset-footprint units for merged blocks (v_quadUnits = block size relative to the footprint), whose edges
+ // shared with a same-styled neighbour are ignored so adjacent blocks join seamlessly.
+ vec2 quadUnits = merged ? v_quadUnits : vec2(1.0);
+ vec2 quadPos = v_cellUV * quadUnits;
+ float distanceToEdge;
+ if (merged) {
+ // Shared edges are pushed far away. Use exact selects, never mix() with the large constant: in float
+ // precision that quantises the distances and turns the corners into staircases.
+ float farAway = 1.0e5;
+ bool leftExternal = mod(floor(v_externalEdges), 2.0) > 0.5;
+ bool rightExternal = mod(floor(v_externalEdges / 2.0), 2.0) > 0.5;
+ bool topExternal = mod(floor(v_externalEdges / 4.0), 2.0) > 0.5;
+ bool bottomExternal = mod(floor(v_externalEdges / 8.0), 2.0) > 0.5;
+ // The rounding has to fit inside the block: half its extent on an axis with both edges exposed, the full
+ // extent with one exposed edge, unlimited when neither edge is exposed.
+ float radiusLimitX = (leftExternal && rightExternal) ? quadUnits.x * 0.5
+ : ((leftExternal || rightExternal) ? quadUnits.x : farAway);
+ float radiusLimitY = (topExternal && bottomExternal) ? quadUnits.y * 0.5
+ : ((topExternal || bottomExternal) ? quadUnits.y : farAway);
+ float blockCornerRadius = min(scaledCornerRadius, min(radiusLimitX, radiusLimitY));
+ float distanceX = min(leftExternal ? quadPos.x : farAway, rightExternal ? quadUnits.x - quadPos.x : farAway);
+ float distanceY = min(topExternal ? quadPos.y : farAway, bottomExternal ? quadUnits.y - quadPos.y : farAway);
+ vec2 cornerDistance = vec2(blockCornerRadius) - vec2(distanceX, distanceY);
+ distanceToEdge = length(max(cornerDistance, 0.0)) + min(max(cornerDistance.x, cornerDistance.y), 0.0) - blockCornerRadius;
+ // Concave corners (two shared edges meeting a diagonal block of another style): carve the cell inset around
+ // the corner point so that block's margin and outline continue around the corner.
+ float reflexCorners = floor(v_externalEdges / 16.0);
+ if (reflexCorners > 0.5) {
+ vec2 cellPos = quadPos / v_cellToUnit;
+ vec2 blockCells = quadUnits / v_cellToUnit;
+ float insetCells = cellInset / max(cellSize - 2.0 * cellInset, 0.001);
+ float reflexDistance = -farAway;
+ if (mod(reflexCorners, 2.0) > 0.5) {
+ reflexDistance = max(reflexDistance, insetCells - length(cellPos));
+ }
+ if (mod(floor(reflexCorners / 2.0), 2.0) > 0.5) {
+ reflexDistance = max(reflexDistance, insetCells - length(cellPos - vec2(blockCells.x, 0.0)));
+ }
+ if (mod(floor(reflexCorners / 4.0), 2.0) > 0.5) {
+ reflexDistance = max(reflexDistance, insetCells - length(cellPos - vec2(0.0, blockCells.y)));
+ }
+ if (mod(floor(reflexCorners / 8.0), 2.0) > 0.5) {
+ reflexDistance = max(reflexDistance, insetCells - length(cellPos - blockCells));
+ }
+ distanceToEdge = max(distanceToEdge, reflexDistance * v_cellToUnit.x);
+ }
+ } else {
+ vec2 cornerDistance = abs(v_cellUV - vec2(0.5)) - vec2(0.5 - scaledCornerRadius);
+ distanceToEdge = length(max(cornerDistance, 0.0)) - scaledCornerRadius;
+ }
float antialiasWidth = fwidth(distanceToEdge);
float coverage = 1.0 - smoothstep(0.0, antialiasWidth, distanceToEdge);
float outline = smoothstep(-outlineWidth - antialiasWidth, -outlineWidth + antialiasWidth, distanceToEdge);
@@ -835,10 +933,12 @@ void main() {
float rightEdge = mod(floor(v_footprintEdges / 2.0), 2.0);
float topEdge = mod(floor(v_footprintEdges / 4.0), 2.0);
float bottomEdge = mod(floor(v_footprintEdges / 8.0), 2.0);
- float leftOutline = leftEdge * (1.0 - smoothstep(0.0, footprintBoundaryWidth, v_cellUV.x));
- float rightOutline = rightEdge * smoothstep(1.0 - footprintBoundaryWidth, 1.0, v_cellUV.x);
- float topOutline = topEdge * (1.0 - smoothstep(0.0, footprintBoundaryWidth, v_cellUV.y));
- float bottomOutline = bottomEdge * smoothstep(1.0 - footprintBoundaryWidth, 1.0, v_cellUV.y);
+ // The boundary band keeps its per-cell width on merged blocks.
+ vec2 boundaryWidth = footprintBoundaryWidth * v_cellToUnit;
+ float leftOutline = leftEdge * (1.0 - smoothstep(0.0, boundaryWidth.x, quadPos.x));
+ float rightOutline = rightEdge * smoothstep(quadUnits.x - boundaryWidth.x, quadUnits.x, quadPos.x);
+ float topOutline = topEdge * (1.0 - smoothstep(0.0, boundaryWidth.y, quadPos.y));
+ float bottomOutline = bottomEdge * smoothstep(quadUnits.y - boundaryWidth.y, quadUnits.y, quadPos.y);
float footprintOutline = max(max(leftOutline, rightOutline), max(topOutline, bottomOutline));
color = mix(color, invalidFootprintBoundaryColor.rgb, footprintOutline);
alpha = mix(alpha, invalidFootprintBoundaryColor.a, footprintOutline);
@@ -889,6 +989,8 @@ local function initGL4Resources()
cornerRadius = CORNER_RADIUS,
simplifiedOutlineScale = SIMPLIFIED_OUTLINE_SCALE,
simplifiedCornerRadiusScale = SIMPLIFIED_CORNER_RADIUS_SCALE,
+ simplifiedReferenceCells = SIMPLIFIED_SIZE_REFERENCE_CELLS,
+ simplifiedSizeFalloff = SIMPLIFIED_SIZE_FALLOFF,
footprintBoundaryEnabled = FOOTPRINT_BOUNDARY_ENABLED and 1.0 or 0.0,
footprintBoundaryWidth = FOOTPRINT_BOUNDARY_WIDTH,
showInvalidFootprintBoundary = SHOW_INVALID_FOOTPRINT_BOUNDARY and 1.0 or 0.0,
@@ -929,7 +1031,8 @@ local function initGL4Resources()
{ id = 1, name = "a_cellData", size = 4 },
{ id = 2, name = "a_color", size = 4 },
{ id = 3, name = "a_outlineColor", size = 4 },
- { id = 4, name = "a_floatOnWater", size = 1 },
+ { id = 4, name = "a_cellExtra", size = 4 },
+ { id = 5, name = "a_footprintSize", size = 2 },
})
batchVAO = glGetVAO()
@@ -941,7 +1044,8 @@ local function initGL4Resources()
{ id = 1, name = "a_cellData", size = 4 },
{ id = 2, name = "a_color", size = 4 },
{ id = 3, name = "a_outlineColor", size = 4 },
- { id = 4, name = "a_floatOnWater", size = 1 },
+ { id = 4, name = "a_cellExtra", size = 4 },
+ { id = 5, name = "a_footprintSize", size = 2 },
})
minimapVAO = glGetVAO()
@@ -1127,6 +1231,249 @@ local function updateExtendedStatuses(
return extendedStatuses, openYardmapStatuses, openYardmapStatusesChanged
end
+-- Style of a cell inside the footprint (extended cells outside the footprint use a distance based alpha instead).
+local function getFootprintCellStyle(status, isOpenYardmapCell, isOpenYardmapTerrainBlocked, footprintIsValid)
+ local color = STATUS_COLORS[status] or STATUS_COLORS[STATUS_BLOCKED]
+ local outlineColor = STATUS_OUTLINE_COLORS[status] or STATUS_OUTLINE_COLORS[STATUS_BLOCKED]
+ local isPlaceableOverObject = footprintIsValid and (status == STATUS_OCCUPIED or status == STATUS_RECLAIMABLE)
+ if isOpenYardmapCell and status ~= STATUS_BLOCKED then
+ color = VALID_FOOTPRINT_COLOR
+ outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR
+ elseif footprintIsValid and status == STATUS_OPEN then
+ color = VALID_FOOTPRINT_COLOR
+ outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR
+ elseif isPlaceableOverObject then
+ outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR
+ end
+ local alpha = isPlaceableOverObject and VALID_FOOTPRINT_COLOR[4] or color[4]
+ local outlineAlpha = outlineColor[4]
+ if OPEN_YARDMAP_ONLY_WHEN_BLOCKED and footprintIsValid and isOpenYardmapCell then
+ alpha = 0
+ outlineAlpha = 0
+ elseif isOpenYardmapCell and not isOpenYardmapTerrainBlocked then
+ alpha = EXTENDED_ALPHA_NEAR
+ outlineAlpha = outlineAlpha * alpha
+ end
+ return color, outlineColor, alpha, outlineAlpha
+end
+
+local function getStyleColorId(color)
+ local id = styleColorIds[color]
+ if not id then
+ styleColorIdCount = styleColorIdCount + 1
+ id = styleColorIdCount
+ styleColorIds[color] = id
+ end
+ return id
+end
+
+-- Merges same-styled cells of a placeable footprint into rectangles. Every rectangle edge is either fully shared
+-- with a same-styled neighbour rectangle (drawn without inset or outline so the blocks join into one shape) or
+-- fully exposed.
+-- Writes 14 numbers per rectangle into rectData: cell x, cell z, width, height (cells), external edge mask plus
+-- 16 * concave corner mask, packed footprint data, fill rgba, outline rgba. Returns the rectangle count.
+local function buildMergedFootprintRects(rectData, statuses, footprint, queuedFootprintConflict)
+ local xsize = footprint.xsize
+ local zsize = footprint.zsize
+ -- Hidden open yardmap cells get no rectangle, which keeps them as holes in the merged shape.
+ local openYardmapCells = footprint.openYardmapCells
+ local cellKeys = mergeCellKeys
+ for cellIndex = 1, xsize * zsize do
+ local status = statuses[cellIndex] or STATUS_BLOCKED
+ local isOpenYardmapCell = (openYardmapCells and openYardmapCells[cellIndex]) or false
+ local color, outlineColor, alpha, outlineAlpha = getFootprintCellStyle(status, isOpenYardmapCell, false, true)
+ if alpha <= 0 and outlineAlpha <= 0 then
+ cellKeys[cellIndex] = false
+ else
+ local key = getStyleColorId(color)
+ + getStyleColorId(outlineColor) * 64
+ + math.floor(alpha * 1000 + 0.5) * 4096
+ + math.floor(outlineAlpha * 1000 + 0.5) * 4194304
+ cellKeys[cellIndex] = key
+ mergeStyleColors[key] = color
+ mergeStyleOutlineColors[key] = outlineColor
+ mergeStyleAlphas[key] = alpha
+ mergeStyleOutlineAlphas[key] = outlineAlpha
+ end
+ end
+
+ local function hasSameKey(xi, zi, key)
+ if xi < 0 or zi < 0 or xi >= xsize or zi >= zsize then
+ return false
+ end
+ return cellKeys[zi * xsize + xi + 1] == key
+ end
+
+ -- Pass 1: horizontal runs whose cells agree on whether the cell above / below is same-styled, so the top and
+ -- bottom edges of a run are uniform. Pass 2 (interleaved): extend the run from the previous row when x, width,
+ -- key and the left / right neighbour status match, keeping every edge of the rectangle uniform.
+ -- Scratch layout per rectangle (10 numbers): x, z, width, height, key, leftShared, rightShared, topShared,
+ -- bottomShared, last row.
+ local scratch = mergeRectData
+ local openRects = mergeOpenRects
+ for openX in pairs(openRects) do
+ openRects[openX] = nil
+ end
+ local rectCount = 0
+ for zi = 0, zsize - 1 do
+ local xi = 0
+ while xi < xsize do
+ local key = cellKeys[zi * xsize + xi + 1]
+ if not key then
+ xi = xi + 1
+ else
+ local topShared = hasSameKey(xi, zi - 1, key)
+ local bottomShared = hasSameKey(xi, zi + 1, key)
+ local width = 1
+ while
+ xi + width < xsize
+ and cellKeys[zi * xsize + xi + width + 1] == key
+ and hasSameKey(xi + width, zi - 1, key) == topShared
+ and hasSameKey(xi + width, zi + 1, key) == bottomShared
+ do
+ width = width + 1
+ end
+ local leftShared = hasSameKey(xi - 1, zi, key)
+ local rightShared = hasSameKey(xi + width, zi, key)
+ local rectIndex = openRects[xi]
+ local rectBase = rectIndex and (rectIndex - 1) * 10
+ if
+ rectBase
+ and scratch[rectBase + 10] == zi - 1
+ and scratch[rectBase + 3] == width
+ and scratch[rectBase + 5] == key
+ and scratch[rectBase + 6] == leftShared
+ and scratch[rectBase + 7] == rightShared
+ then
+ scratch[rectBase + 4] = scratch[rectBase + 4] + 1
+ scratch[rectBase + 9] = bottomShared
+ scratch[rectBase + 10] = zi
+ else
+ rectCount = rectCount + 1
+ rectBase = (rectCount - 1) * 10
+ scratch[rectBase + 1] = xi
+ scratch[rectBase + 2] = zi
+ scratch[rectBase + 3] = width
+ scratch[rectBase + 4] = 1
+ scratch[rectBase + 5] = key
+ scratch[rectBase + 6] = leftShared
+ scratch[rectBase + 7] = rightShared
+ scratch[rectBase + 8] = topShared
+ scratch[rectBase + 9] = bottomShared
+ scratch[rectBase + 10] = zi
+ openRects[xi] = rectCount
+ end
+ xi = xi + width
+ end
+ end
+ end
+
+ local packedFlags = 16 + (queuedFootprintConflict and 32 or 0)
+ local dataIndex = 0
+ for rectIndex = 0, rectCount - 1 do
+ local rectBase = rectIndex * 10
+ local rectX = scratch[rectBase + 1]
+ local rectZ = scratch[rectBase + 2]
+ local rectWidth = scratch[rectBase + 3]
+ local rectHeight = scratch[rectBase + 4]
+ local key = scratch[rectBase + 5]
+ local leftShared = scratch[rectBase + 6]
+ local rightShared = scratch[rectBase + 7]
+ local topShared = scratch[rectBase + 8]
+ local bottomShared = scratch[rectBase + 9]
+ local externalEdges = (leftShared and 0 or 1)
+ + (rightShared and 0 or 2)
+ + (topShared and 0 or 4)
+ + (bottomShared and 0 or 8)
+ -- Concave corners: two shared edges meeting a diagonal cell of another style. The shader wraps the margin
+ -- and outline of the neighbouring block around such a corner point so hole outlines stay continuous.
+ local reflexCorners = (topShared and leftShared and not hasSameKey(rectX - 1, rectZ - 1, key) and 1 or 0)
+ + (topShared and rightShared and not hasSameKey(rectX + rectWidth, rectZ - 1, key) and 2 or 0)
+ + (bottomShared and leftShared and not hasSameKey(rectX - 1, rectZ + rectHeight, key) and 4 or 0)
+ + (bottomShared and rightShared and not hasSameKey(rectX + rectWidth, rectZ + rectHeight, key) and 8 or 0)
+ local footprintEdges = (rectX == 0 and 1 or 0)
+ + (rectX + rectWidth == xsize and 2 or 0)
+ + (rectZ == 0 and 4 or 0)
+ + (rectZ + rectHeight == zsize and 8 or 0)
+ local color = mergeStyleColors[key]
+ local outlineColor = mergeStyleOutlineColors[key]
+ rectData[dataIndex + 1] = rectX
+ rectData[dataIndex + 2] = rectZ
+ rectData[dataIndex + 3] = rectWidth
+ rectData[dataIndex + 4] = rectHeight
+ rectData[dataIndex + 5] = externalEdges + reflexCorners * 16
+ rectData[dataIndex + 6] = footprintEdges + packedFlags
+ rectData[dataIndex + 7] = color[1]
+ rectData[dataIndex + 8] = color[2]
+ rectData[dataIndex + 9] = color[3]
+ rectData[dataIndex + 10] = mergeStyleAlphas[key]
+ rectData[dataIndex + 11] = outlineColor[1]
+ rectData[dataIndex + 12] = outlineColor[2]
+ rectData[dataIndex + 13] = outlineColor[3]
+ rectData[dataIndex + 14] = mergeStyleOutlineAlphas[key]
+ dataIndex = dataIndex + 14
+ end
+ return rectCount
+end
+
+-- Per-cell statuses for a placement the engine did not evaluate for us (pregame, extractor snap target).
+local function fillPredictedStatuses(statusList, unitDef, x, buildHeight, z, footprint, placementValid)
+ local statusIndex = 0
+ for zi = 0, footprint.zsize - 1 do
+ for xi = 0, footprint.xsize - 1 do
+ statusIndex = statusIndex + 1
+ local status = STATUS_BLOCKED
+ if placementValid then
+ status = getPredictedCellStatus(
+ unitDef,
+ x + (xi - footprint.halfXsize) * SQUARE_SIZE,
+ z + (zi - footprint.halfZsize) * SQUARE_SIZE,
+ buildHeight
+ )
+ -- The engine accepted the order, so a cell the prediction calls blocked is really placeable
+ -- (e.g. an extractor upgrade over stackable / build-only yardmap squares).
+ if status == STATUS_BLOCKED then
+ status = STATUS_OPEN
+ end
+ end
+ statusList[statusIndex] = status
+ end
+ end
+ for index = statusIndex + 1, #statusList do
+ statusList[index] = nil
+ end
+end
+
+-- When the extractor snap widget targets a resource spot for the active build command, the preview belongs at
+-- that spot rather than at the cursor. Returns the snapped x, z and predicted statuses, or nil.
+local function getExtractorSnapPlacement(unitDefID, facing, footprint)
+ if not FOLLOW_EXTRACTOR_SNAP then
+ return nil
+ end
+ local extractorSnap = WG.ExtractorSnap
+ local snapPosition = extractorSnap and extractorSnap.position
+ if not snapPosition then
+ return nil
+ end
+ if spGetGameFrame() > 0 then
+ local _, activeCmdID = spGetActiveCommand()
+ if not activeCmdID or -activeCmdID ~= unitDefID then
+ return nil
+ end
+ end
+ local x, buildHeight, z = spPos2BuildPos(unitDefID, snapPosition.x, snapPosition.y, snapPosition.z, facing)
+ if not x or not buildHeight or not z then
+ return nil
+ end
+ local unitDef = UnitDefs[unitDefID]
+ if not unitDef then
+ return nil
+ end
+ local placementValid = spTestBuildOrder(unitDefID, x, buildHeight, z, facing) ~= 0
+ fillPredictedStatuses(snapStatuses, unitDef, x, buildHeight, z, footprint, placementValid)
+ return x, z, snapStatuses
+end
+
function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
--Spring.Echo("DrawBuildSquare called with unitDefID:", unitDefID, "x:", x, "z:", z, "facing:", facing, "statuses length:", #statuses)
local extendedCells, gameFrame, footprintStatusCheckPeriod, sequenceIndex, simplified = getEffectiveExtendedCells()
@@ -1134,6 +1481,10 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
if not footprint then
return
end
+ local snapX, snapZ, snapCellStatuses = getExtractorSnapPlacement(unitDefID, facing, footprint)
+ if snapX then
+ x, z, statuses = snapX, snapZ, snapCellStatuses
+ end
local footprintCellCount = footprint.cellCount
local footprintIsValid = true
for cellIdx = 1, footprintCellCount do
@@ -1159,9 +1510,8 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
return
end
-- Avoid a one-frame VBO overflow before the next frame enables global simplification.
- simplified = simplified
- or drawSquareCellCount + sourceCellCount > SIMPLIFIED_CELL_THRESHOLD
- or needsDistanceSimplification(footprint, placementX, placementZ)
+ simplified = simplified or drawSquareCellCount + sourceCellCount > SIMPLIFIED_CELL_THRESHOLD
+ local merged = COMBINE_VALID_FOOTPRINT_CELLS and not simplified and footprintIsValid and extendedCells == 0
local renderCache = orderedPreviewCaches[sequenceIndex]
if
extendedCells == 0
@@ -1173,6 +1523,7 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
and renderCache.colorValid
and renderCache.extendedStatusCount == 0
and renderCache.simplifiedMode == simplified
+ and renderCache.mergedMode == merged
and renderCache.queuedFootprintConflict == queuedFootprintConflict
then
local previewWasDrawnLastFrame = renderCache.lastDrawFrame == extendedCellsDrawFrame - 1
@@ -1191,11 +1542,16 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
end
end
- local cellScale = COMBINE_FOUR_CELLS and xsize % 2 == 0 and zsize % 2 == 0 and extendedCells % 2 == 0 and 2 or 1
+ local cellScale = not merged
+ and COMBINE_FOUR_CELLS
+ and xsize % 2 == 0
+ and zsize % 2 == 0
+ and extendedCells % 2 == 0
+ and 2
+ or 1
local renderXSize = totalXSize / cellScale
local renderZSize = totalZSize / cellScale
local renderCellCount = renderXSize * renderZSize
- local renderInstanceCount = simplified and 1 or renderCellCount
drawSquareCellCount = drawSquareCellCount + sourceCellCount
local sx = centerGridX - footprint.halfXsize
local sz = centerGridZ - footprint.halfZsize
@@ -1216,18 +1572,20 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
and renderCache.colorValid
and renderCache.extendedStatusCount == 0
and renderCache.simplifiedMode == simplified
+ and renderCache.mergedMode == merged
and not statusCheckDue
then
collectPreview(
renderCache,
sx * SQUARE_SIZE,
sz * SQUARE_SIZE,
- renderInstanceCount,
+ simplified and 1 or (merged and renderCache.mergedRectCount) or renderCellCount,
xsize,
zsize,
extendedCells,
cellScale,
- simplified
+ simplified,
+ merged
)
return
end
@@ -1252,6 +1610,7 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
local extendedStatusCount = sourceCellCount - footprintCellCount
local needsColorUpload = not renderCache.colorValid
or renderCache.simplifiedMode ~= simplified
+ or renderCache.mergedMode ~= merged
or openYardmapStatusesChanged
or renderCache.queuedFootprintConflict ~= queuedFootprintConflict
@@ -1282,6 +1641,7 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
local colorData = renderCache.colorData
renderCache.colorValid = true
renderCache.simplifiedMode = simplified
+ renderCache.mergedMode = merged
renderCache.sourceCellCount = sourceCellCount
renderCache.statusCheckGameFrame = gameFrame
renderCache.extendedStatusCount = extendedStatusCount
@@ -1318,6 +1678,30 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
zsize,
extendedCells,
cellScale,
+ true,
+ false
+ )
+ return
+ end
+ if merged then
+ local rectCount = buildMergedFootprintRects(colorData, statuses, footprint, queuedFootprintConflict)
+ for index = rectCount * 14 + 1, renderCache.colorDataLength or 0 do
+ colorData[index] = nil
+ end
+ renderCache.colorDataLength = rectCount * 14
+ renderCache.mergedRectCount = rectCount
+ renderCache.colorRevision = (renderCache.colorRevision or 0) + 1
+ tracy.ZoneEnd()
+ collectPreview(
+ renderCache,
+ sx * SQUARE_SIZE,
+ sz * SQUARE_SIZE,
+ rectCount,
+ xsize,
+ zsize,
+ extendedCells,
+ cellScale,
+ false,
true
)
return
@@ -1394,41 +1778,17 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
local xi = renderXi - renderExtendedCells
local zi = renderZi - renderExtendedCells
local isFootprintCell = xi >= 0 and xi < renderFootprintXSize and zi >= 0 and zi < renderFootprintZSize
- local color = STATUS_COLORS[status] or STATUS_COLORS[STATUS_BLOCKED]
- local outlineColor = STATUS_OUTLINE_COLORS[status] or STATUS_OUTLINE_COLORS[STATUS_BLOCKED]
- if isOpenYardmapCell and status ~= STATUS_BLOCKED then
- color = VALID_FOOTPRINT_COLOR
- outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR
- elseif isFootprintCell and footprintIsValid and status == STATUS_OPEN then
- color = VALID_FOOTPRINT_COLOR
- outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR
- elseif
- isFootprintCell
- and footprintIsValid
- and (status == STATUS_OCCUPIED or status == STATUS_RECLAIMABLE)
- then
- outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR
- elseif not isFootprintCell and status ~= STATUS_BLOCKED then
- color = VALID_FOOTPRINT_COLOR
- outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR
- end
-
- local alpha = color[4]
- local outlineAlpha = outlineColor[4]
- if
- isFootprintCell
- and footprintIsValid
- and (status == STATUS_OCCUPIED or status == STATUS_RECLAIMABLE)
- then
- alpha = VALID_FOOTPRINT_COLOR[4]
- end
- if OPEN_YARDMAP_ONLY_WHEN_BLOCKED and footprintIsValid and isOpenYardmapCell then
- alpha = 0
- outlineAlpha = 0
- elseif isOpenYardmapCell and not isOpenYardmapTerrainBlocked then
- alpha = EXTENDED_ALPHA_NEAR
- outlineAlpha = outlineAlpha * alpha
- elseif not isFootprintCell then
+ local color, outlineColor, alpha, outlineAlpha
+ if isFootprintCell then
+ color, outlineColor, alpha, outlineAlpha =
+ getFootprintCellStyle(status, isOpenYardmapCell, isOpenYardmapTerrainBlocked, footprintIsValid)
+ else
+ color = STATUS_COLORS[status] or STATUS_COLORS[STATUS_BLOCKED]
+ outlineColor = STATUS_OUTLINE_COLORS[status] or STATUS_OUTLINE_COLORS[STATUS_BLOCKED]
+ if status ~= STATUS_BLOCKED then
+ color = VALID_FOOTPRINT_COLOR
+ outlineColor = VALID_FOOTPRINT_OUTLINE_COLOR
+ end
local dx = xi < 0 and -xi or (xi - renderFootprintXSize + 1)
local dz = zi < 0 and -zi or (zi - renderFootprintZSize + 1)
local distance = math.max(dx, dz)
@@ -1438,7 +1798,7 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
local alphaFar = alphaMidpoint + (EXTENDED_ALPHA_FAR - alphaMidpoint) * alphaRangeScale
local t = (distance - 1) / math.max(1, renderExtendedCells - 1)
alpha = alphaNear + (alphaFar - alphaNear) * t
- outlineAlpha = outlineAlpha * alpha
+ outlineAlpha = outlineColor[4] * alpha
end
colorDataIndex = colorDataIndex + 1
@@ -1472,12 +1832,13 @@ function widget:DrawBuildSquare(unitDefID, x, z, facing, statuses)
renderCache,
sx * SQUARE_SIZE,
sz * SQUARE_SIZE,
- renderInstanceCount,
+ simplified and 1 or (merged and renderCache.mergedRectCount) or renderCellCount,
xsize,
zsize,
extendedCells,
cellScale,
- simplified
+ simplified,
+ merged
)
end
@@ -1521,26 +1882,7 @@ local function collectPregameBuildSquare()
end
local placementValid = spTestBuildOrder(unitDefID, x, buildHeight, z, facing) ~= 0
- local statusIndex = 0
- for zi = 0, footprint.zsize - 1 do
- for xi = 0, footprint.xsize - 1 do
- statusIndex = statusIndex + 1
- if placementValid then
- pregameStatuses[statusIndex] = getPredictedCellStatus(
- unitDef,
- x + (xi - footprint.halfXsize) * SQUARE_SIZE,
- z + (zi - footprint.halfZsize) * SQUARE_SIZE,
- buildHeight
- )
- else
- pregameStatuses[statusIndex] = STATUS_BLOCKED
- end
- end
- end
- for index = statusIndex + 1, pregameStatusCount do
- pregameStatuses[index] = nil
- end
- pregameStatusCount = statusIndex
+ fillPredictedStatuses(pregameStatuses, unitDef, x, buildHeight, z, footprint, placementValid)
widget:DrawBuildSquare(unitDefID, x, z, facing, pregameStatuses)
end
@@ -1585,11 +1927,26 @@ local function rebuildBatchBuffer()
minimapInstanceData[minimapDataIndex] = minimapOutlineColor[4]
minimapDataIndex = minimapDataIndex + 1
minimapInstanceData[minimapDataIndex] = renderCache.floatOnWater
+ minimapDataIndex = minimapDataIndex + 1
+ minimapInstanceData[minimapDataIndex] = 1 -- simplified quad
+ minimapDataIndex = minimapDataIndex + 1
+ minimapInstanceData[minimapDataIndex] = 0
+ minimapDataIndex = minimapDataIndex + 1
+ minimapInstanceData[minimapDataIndex] = 0
+ minimapDataIndex = minimapDataIndex + 1
+ minimapInstanceData[minimapDataIndex] = renderCache.batchXsize
+ minimapDataIndex = minimapDataIndex + 1
+ minimapInstanceData[minimapDataIndex] = renderCache.batchZsize
minimapCount = minimapCount + 1
end
if not capacityReached and instanceCount + renderCache.batchNumCells <= MAX_BATCH_CELLS then
local colorData = renderCache.colorData
+ local originX = renderCache.batchOriginX
+ local originZ = renderCache.batchOriginZ
+ local floatOnWater = renderCache.floatOnWater
+ local footprintXsize = renderCache.batchXsize
+ local footprintZsize = renderCache.batchZsize
if renderCache.batchSimplified then
dataIndex = dataIndex + 1
batchInstanceData[dataIndex] = renderCache.batchOriginX
@@ -1616,30 +1973,67 @@ local function rebuildBatchBuffer()
dataIndex = dataIndex + 1
batchInstanceData[dataIndex] = colorData[8]
dataIndex = dataIndex + 1
- batchInstanceData[dataIndex] = renderCache.floatOnWater
+ batchInstanceData[dataIndex] = floatOnWater
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = 1 -- simplified quad
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = 0
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = 0
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = footprintXsize
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = footprintZsize
+ elseif renderCache.batchMerged then
+ for rectIndex = 0, renderCache.batchNumCells - 1 do
+ local rectDataIndex = rectIndex * 14
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = originX + colorData[rectDataIndex + 1] * SQUARE_SIZE
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = originZ + colorData[rectDataIndex + 2] * SQUARE_SIZE
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = colorData[rectDataIndex + 3] * SQUARE_SIZE
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = colorData[rectDataIndex + 4] * SQUARE_SIZE
+ for colorIndex = 7, 14 do
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = colorData[rectDataIndex + colorIndex]
+ end
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = floatOnWater
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = 2 -- merged block
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = colorData[rectDataIndex + 5]
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = colorData[rectDataIndex + 6]
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = footprintXsize
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = footprintZsize
+ end
else
local cellScale = renderCache.batchCellScale or 1
+ local quadSize = SQUARE_SIZE * cellScale
local geometryData = getCellGeometry(
renderCache.batchXsize / cellScale,
renderCache.batchZsize / cellScale,
renderCache.batchExtendedCells / cellScale,
cellScale
)
- local footprintValidityFlag = renderCache.footprintIsValid and 16 or 0
- local queuedFootprintConflictFlag = renderCache.queuedFootprintConflict and 32 or 0
+ local packedFlags = (renderCache.footprintIsValid and 16 or 0)
+ + (renderCache.queuedFootprintConflict and 32 or 0)
for cellIndex = 0, renderCache.batchNumCells - 1 do
- local geometryIndex = cellIndex * 4
+ local geometryIndex = cellIndex * 3
local colorIndex = cellIndex * 8
dataIndex = dataIndex + 1
- batchInstanceData[dataIndex] = geometryData[geometryIndex + 1] + renderCache.batchOriginX
+ batchInstanceData[dataIndex] = geometryData[geometryIndex + 1] + originX
dataIndex = dataIndex + 1
- batchInstanceData[dataIndex] = geometryData[geometryIndex + 2] + renderCache.batchOriginZ
+ batchInstanceData[dataIndex] = geometryData[geometryIndex + 2] + originZ
dataIndex = dataIndex + 1
- batchInstanceData[dataIndex] = geometryData[geometryIndex + 3]
+ batchInstanceData[dataIndex] = quadSize
dataIndex = dataIndex + 1
- batchInstanceData[dataIndex] = geometryData[geometryIndex + 4]
- + footprintValidityFlag
- + queuedFootprintConflictFlag
+ batchInstanceData[dataIndex] = quadSize
dataIndex = dataIndex + 1
batchInstanceData[dataIndex] = colorData[colorIndex + 1]
dataIndex = dataIndex + 1
@@ -1657,7 +2051,17 @@ local function rebuildBatchBuffer()
dataIndex = dataIndex + 1
batchInstanceData[dataIndex] = colorData[colorIndex + 8]
dataIndex = dataIndex + 1
- batchInstanceData[dataIndex] = renderCache.floatOnWater
+ batchInstanceData[dataIndex] = floatOnWater
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = 0 -- regular cell
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = 0
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = geometryData[geometryIndex + 3] + packedFlags
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = footprintXsize
+ dataIndex = dataIndex + 1
+ batchInstanceData[dataIndex] = footprintZsize
end
end
instanceCount = instanceCount + renderCache.batchNumCells
diff --git a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua
index ce4f54eec6e..bc99f5b4b20 100644
--- a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua
+++ b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua
@@ -20,7 +20,8 @@ end
-- engine's radar LOS (LosMap.cpp: midpoint-circle disk, rays, CastLos angle test) per radar cell.
-- 2. Smoothing pass (every frame, a few thousand texels): a ping-ponged state texture eases towards
-- the coverage, so cubes rise/sink smoothly instead of popping while dragging.
--- 3. Cube pass: one instanced draw of a unit cube per 16 elmo grid cell. The vertex shader looks up
+-- 3. Cube pass: one instanced draw of a unit cube per cube of the NxN block inside each radar cell
+-- (CUBES_PER_CELL by radarMipLevel, cube size as a fraction of the spacing). The vertex shader looks up
-- the radar cell the cube is in, samples the heightmap and animates the cube; the fragment shader
-- does per-face shading and zoom-independent edge lines. Only cells under the screen's ground
-- footprint are drawn; flat shapes draw a single quad per cell. Optionally (LOD_MAX_INSTANCES) the
@@ -28,15 +29,18 @@ end
------------------------------------------------------------------------------------------------
-- Tunables
-local CUBE_SPACING = 16 -- elmos between cube centers; must divide the radar cell size
-local CUBE_WIDTH = 5.5 -- elmos
+-- Cubes are laid out as an NxN block centered inside every radar cell (cell size = 8 << radarMipLevel elmo);
+-- N by radarMipLevel, so the look stays consistent when the game's radar resolution changes.
+local CUBES_PER_CELL = { [1] = 2, [2] = 3, [3] = 5, [4] = 8 }
+local CUBE_FILL = 0.28 -- cube width as a fraction of the cube spacing (spacing = radar cell size / N)
local CUBE_SINK = 2 -- elmos the cube base is pushed below ground, so cubes never float on slopes
local CUBE_SHAPE = "tile" -- default shape, see CUBE_SHAPES; switch at runtime with WG.radarPreview.setShape(name)
local CUBE_SHAPES = {
- -- height at full coverage (elmo), lift of the top face above ground (elmo), conform = top face tilts with the terrain
- cube = { height = 6, lift = 0, conform = 0 },
- slab = { height = 3, lift = 0, conform = 0 },
- tile = { height = 0.5, lift = 0.5, conform = 0 },
+ -- height at full coverage as a multiple of the cube width, lift of the top face above ground (elmo),
+ -- conform = top face tilts with the terrain
+ cube = { height = 2.0, lift = 0, conform = 0 },
+ slab = { height = 1.0, lift = 0, conform = 0 },
+ tile = { height = 0.5, lift = 0, conform = 0 },
flat = { height = 0, lift = 1.5, conform = 1 }, -- flat square
}
local LIFT_PER_DISTANCE = 0.001 -- extra lift per elmo of camera distance, keeps flat shapes above the terrain LOD mesh
@@ -45,7 +49,7 @@ local COVERAGE_REFRESH_SECONDS = 1.0 -- periodic heightmap/coverage rebuild so t
local SMOOTH_RATE = 14 -- 1/s, how fast the cubes follow coverage changes (higher = snappier)
local LOD_MAX_INSTANCES = 0 -- > 0: thin the grid to double spacing once more cubes than this are on screen (cubes visibly fill in/out with zoom; try 50000 on integrated graphics)
local FOOTPRINT_MARGIN = 48 -- elmos of slack around the screen's ground footprint
-local MAX_RADIUS_CELLS = 160 -- sanity limit of the radar radius in cells (160 * 64 = 10240 elmo)
+local MAX_RADIUS_CELLS = 256 -- sanity limit of the radar radius in cells (coverage texture and ray table size)
local RAY_SSBO_BINDING = 5 -- shader storage binding of the per-radius ray table (4, 6, 7 are used elsewhere in BAR)
-- With deferred map/model rendering the cubes are occluded via the g-buffer depths (terrain and units)
@@ -61,19 +65,21 @@ local shaderConfig = {
SWEEP_TRAIL = 30.0, -- degrees: the trail fades out this far behind the sweep's leading edge
SWEEP_BEAM = 9.0, -- degrees: width of the bright leading edge
SWEEP_STRENGTH = 0.55, -- how much the sweep brightens/raises cubes
- SPAWN_SPEED = 1.6, -- radar ranges per second the spawn ripple travels outward
- SPAWN_BUMP = 0.3, -- width of the overshoot behind the ripple front, in radar ranges
+ SPAWN_SPEED = 2.5, -- radar ranges per second the spawn ripple travels outward
+ SPAWN_BUMP = 0.25, -- width of the overshoot behind the ripple front, in radar ranges
PULSE_SPACING = 180.0, -- elmos between the outward travelling wave rings
PULSE_SPEED = 90.0, -- elmos per second the rings travel
- PULSE_POWER = 3.0, -- higher = narrower rings
- PULSE_STRENGTH = 0.75, -- how much the rings raise/brighten cubes
+ PULSE_POWER = 4.5, -- higher = narrower rings
+ PULSE_STRENGTH = 1.0, -- how much the rings raise/brighten cubes
+ EDGE_STRENGTH = 0.15, -- how much cubes at the coverage boundary (next to an uncovered radar cell) brighten; 0 disables
+ RIM_STRENGTH = 0.25, -- how much the outermost ring of cubes brightens; 0 disables
TILE_MAX_TILT = 20.0, -- degrees: flat tiles follow the terrain slope up to this angle
TILE_CLIFF_START = 35.0, -- degrees: terrain steeper than this starts flattening the tiles again
TILE_CLIFF_END = 55.0, -- degrees: terrain steeper than this gets flat tiles (cliffs)
BASE_COLOR = "vec3(0.22, 0.85, 0.50)",
HIGHLIGHT_COLOR = "vec3(0.65, 1.00, 0.80)",
- BASE_ALPHA = 0.3,
- LINE_ALPHA = 0.3, -- opacity of the cube edge lines
+ BASE_ALPHA = 0.5,
+ LINE_ALPHA = 0.5, -- opacity of the cube edge lines
}
-- Engine radar model (rts/Sim/Misc/LosHandler.cpp, LosMap.cpp)
@@ -89,6 +95,11 @@ local SQUARE_SIZE = 8
local RADAR_CELL = SQUARE_SIZE * 2 ^ RADAR_MIP_LEVEL -- elmos per radar cell
local HEIGHT_BUCKET = 2 ^ (RADAR_MIP_LEVEL + 2) -- emitter heights are quantized to buckets of this size
+-- derived cube grid: N cubes per radar cell edge (fallback: about one cube per 13 elmo)
+local CUBES_PER_CELL_EDGE = CUBES_PER_CELL[RADAR_MIP_LEVEL] or math.max(1, math.floor(RADAR_CELL / 13 + 0.5))
+local CUBE_SPACING = RADAR_CELL / CUBES_PER_CELL_EDGE -- elmos between cube centers
+local CUBE_WIDTH = CUBE_FILL * CUBE_SPACING -- elmos
+
-- Localized functions for performance
local mathFloor = math.floor
local mathCeil = math.ceil
@@ -129,8 +140,12 @@ do
local dims = Spring.GetUnitDefDimensions(unitDefID)
-- ILosType::GetRadius: (radarDistance / SQUARE_SIZE) >> radarMipLevel, integer arithmetic
local radiusCells = mathFloor(mathFloor(unitDef.radarDistance / SQUARE_SIZE) / 2 ^ RADAR_MIP_LEVEL)
+ if radiusCells > MAX_RADIUS_CELLS then
+ spEcho("Sensor Ranges Radar Preview: " .. unitDef.name .. " radar radius clamped to " .. MAX_RADIUS_CELLS .. " cells")
+ radiusCells = MAX_RADIUS_CELLS
+ end
radarDefs[-unitDefID] = {
- radiusCells = mathMin(radiusCells, MAX_RADIUS_CELLS),
+ radiusCells = radiusCells,
emitHeight = unitDef.radarEmitHeight or 0,
midY = (dims and dims.midy) or 0, -- unit->midPos.y - unit->pos.y
}
@@ -215,7 +230,7 @@ local cubeShaderCache = {
},
uniformFloat = {
radarcenter_range = { 0, 0, 0, 2000 },
- gridParams = { RADAR_CELL, 1, CUBE_SPACING, 1 },
+ gridParams = { RADAR_CELL, 1, CUBE_SPACING, CUBES_PER_CELL_EDGE },
lookupParams = { 0, 0, 1, 0 },
shapeParams = { CUBE_WIDTH, 6, CUBE_SINK, 0 },
animParams = { 0, 0, 0, 0 },
@@ -422,7 +437,6 @@ local function makeSet(radiusCells)
local set = {
radius = radiusCells,
N = coverageCells, -- coverage texels per side
- M = 2 * mathCeil((radiusCells + 2) * RADAR_CELL / CUBE_SPACING) + 1, -- cube cells per side
target = makeDataTexture(coverageCells, coverageCells, GL_R16F, GL.NEAREST),
state = { -- R = smoothed coverage, G = smoothed boundary factor
makeDataTexture(coverageCells, coverageCells, GL_RG16F, stateFilter),
@@ -616,21 +630,22 @@ local function getScreenFootprint(camX, camY, camZ)
return minX, maxX, minZ, maxZ
end
--- Which cube cells to draw this frame: the screen footprint clipped to the grid. With LOD_MAX_INSTANCES
--- the grid blends to double spacing once more cubes than that would be on screen (far zoom).
+-- Which cubes to draw this frame: the radar disc's cubes (absolute cube grid indices, CUBES_PER_CELL_EDGE
+-- per radar cell) clipped to the screen footprint. With LOD_MAX_INSTANCES the grid blends to double
+-- spacing once more cubes than that would be on screen (far zoom).
-- Returns x0, z0, cellsX, cellsZ, stride, lodBlend.
-local function getCubeWindow(set, cx, cz, camX, camY, camZ)
- local M = set.M
- local half = (M - 1) * 0.5
-
- local x0, x1, z0, z1 = 0, M - 1, 0, M - 1
+local function getCubeWindow(set, bx, bz, camX, camY, camZ)
+ local n = CUBES_PER_CELL_EDGE
+ local radius = set.radius
+ local x0, x1 = (bx - radius) * n, (bx + radius + 1) * n - 1
+ local z0, z1 = (bz - radius) * n, (bz + radius + 1) * n - 1
local minX, maxX, minZ, maxZ = getScreenFootprint(camX, camY, camZ)
if minX then
local margin = mathCeil(FOOTPRINT_MARGIN / CUBE_SPACING)
- x0 = mathMax(0, mathFloor((minX - cx) / CUBE_SPACING + half) - margin)
- x1 = mathMin(M - 1, mathCeil((maxX - cx) / CUBE_SPACING + half) + margin)
- z0 = mathMax(0, mathFloor((minZ - cz) / CUBE_SPACING + half) - margin)
- z1 = mathMin(M - 1, mathCeil((maxZ - cz) / CUBE_SPACING + half) + margin)
+ x0 = mathMax(x0, mathFloor(minX / CUBE_SPACING) - margin)
+ x1 = mathMin(x1, mathFloor(maxX / CUBE_SPACING) + margin)
+ z0 = mathMax(z0, mathFloor(minZ / CUBE_SPACING) - margin)
+ z1 = mathMin(z1, mathFloor(maxZ / CUBE_SPACING) + margin)
end
if x1 < x0 or z1 < z0 then
return 0, 0, 0, 0, 1, 0
@@ -710,8 +725,7 @@ function widget:DrawWorld()
local radius = def.radiusCells
local range = radius * RADAR_CELL
- local cx = mathFloor((mousepos[1] + CUBE_SPACING * 0.5) / CUBE_SPACING) * CUBE_SPACING
- local cz = mathFloor((mousepos[3] + CUBE_SPACING * 0.5) / CUBE_SPACING) * CUBE_SPACING
+ local cx, cz = mousepos[1], mousepos[3] -- center of the animations; the cube grid itself is world-fixed
-- frame bookkeeping: a gap in draw frames or a different radar means the preview just (re)appeared
local now = osClock()
@@ -769,7 +783,7 @@ function widget:DrawWorld()
local camX, camY, camZ = spGetCameraPosition()
local dx, dy, dz = camX - cx, camY - midY, camZ - cz
local camDist = mathSqrt(dx * dx + dy * dy + dz * dz)
- local x0, z0, cellsX, cellsZ, stride, lodBlend = getCubeWindow(set, cx, cz, camX, camY, camZ)
+ local x0, z0, cellsX, cellsZ, stride, lodBlend = getCubeWindow(set, bx, bz, camX, camY, camZ)
if cellsX > 0 and cellsZ > 0 then
gl.Texture(0, "$heightmap")
gl.Texture(1, nextTex)
@@ -786,9 +800,9 @@ function widget:DrawWorld()
gl.Culling(GL.BACK)
cubeShader:Activate()
cubeShader:SetUniform("radarcenter_range", cx, losHeight, cz, range)
- cubeShader:SetUniform("gridParams", RADAR_CELL, set.N, CUBE_SPACING, set.M)
+ cubeShader:SetUniform("gridParams", RADAR_CELL, set.N, CUBE_SPACING, CUBES_PER_CELL_EDGE)
cubeShader:SetUniform("lookupParams", bx, bz, radius, 0)
- cubeShader:SetUniform("shapeParams", CUBE_WIDTH, shape.height, CUBE_SINK, shape.lift + camDist * LIFT_PER_DISTANCE)
+ cubeShader:SetUniform("shapeParams", CUBE_WIDTH, shape.height * CUBE_WIDTH, CUBE_SINK, shape.lift + camDist * LIFT_PER_DISTANCE)
cubeShader:SetUniform("animParams", now, now - spawnStart, lodBlend, shape.conform)
cubeShader:SetUniform("windowParams", x0, z0, cellsX, stride)
if shape.height > 0 then
diff --git a/modules/unit_idle_states.lua b/modules/unit_idle_states.lua
new file mode 100644
index 00000000000..263506e4072
--- /dev/null
+++ b/modules/unit_idle_states.lua
@@ -0,0 +1,69 @@
+-- The engine misses a very large portion of its one side of the idle responsibility:
+-- A factory's build queue empties through CFactoryCAI::ExecuteStop, which is just a pop;
+-- multi-command removals only "finish" the first command removed, so tend not to report;
+-- units that are built or spawned are not marked as idle, though they have no commands.
+-- Our code also considers newly-created units as neither idle nor busy, at the moment.
+
+-- In addition, units may have "idle tasks", as opposed to "busy tasks". These are not
+-- issued via player input but from code. Code that issues idle tasks can detect idleness
+-- via an empty command queue rather than by isIdle to avoid issuing multiple idle tasks.
+
+local CMD_REPAIR = CMD.REPAIR
+local CMD_MOVE = CMD.MOVE
+local OPT_INTERNAL = CMD.OPT_INTERNAL -- Imperfect but acceptable marker for idle tasks.
+
+-- Idle tasks are a pure accident of the many ways units receive commands via the engine.
+-- We identify them by a command + params + internal triple. They have no simple summary.
+local IDLE_TASK_PARAMS = {
+ [CMD_REPAIR] = 1, -- a targetID
+ [CMD_MOVE] = 3, -- a position
+}
+--
+-- Post-resurrection repair is not currently idle, by the same engine property that makes
+-- most command-tracking impossible, which actually helps us here. It counts, that's a W.
+-- Autotargeting is not currently idle, either, though that seems subject to change.
+
+local bit_and = math.bit_and
+
+local spGetFactoryCommandCount = Spring.GetFactoryCommandCount
+local spGetUnitCommandCount = Spring.GetUnitCommandCount
+local spGetUnitCurrentCommand = Spring.GetUnitCurrentCommand
+local spGetUnitIsBeingBuilt = Spring.GetUnitIsBeingBuilt
+
+local function inIdleTaskAtIndex(unitID, index)
+ local cmdID, cmdOptions, _, _, secondParam, _, fourthParam = spGetUnitCurrentCommand(unitID, index)
+ local params = cmdID and IDLE_TASK_PARAMS[cmdID]
+ if not params or bit_and(cmdOptions, OPT_INTERNAL) == 0 then
+ return false
+ end
+ -- This is a fast parameter count but is not very future-proof.
+ return (params == 1 and secondParam == nil) or (params == 3 and fourthParam == nil)
+end
+
+---Whether everything in the unit's command queue is an idle task.
+local function inIdleTask(unitID, commandCount)
+ for index = 1, commandCount do
+ if not inIdleTaskAtIndex(unitID, index) then
+ return false
+ end
+ end
+ return true
+end
+
+local function isIdle(unitID, unitDefID)
+ -- Taking after base CUnit::IsIdle:
+ if spGetUnitIsBeingBuilt(unitID) then
+ return false
+ end
+
+ if UnitDefs[unitDefID].isFactory then
+ return spGetFactoryCommandCount(unitID) == 0
+ end
+
+ local commandCount = spGetUnitCommandCount(unitID)
+ return commandCount == 0 or inIdleTask(unitID, commandCount)
+end
+
+return {
+ IsIdle = isIdle,
+}
diff --git a/spec/luarules/synthetic_callins_spec.lua b/spec/luarules/synthetic_callins_spec.lua
index 35f36d409fe..302f8f79109 100644
--- a/spec/luarules/synthetic_callins_spec.lua
+++ b/spec/luarules/synthetic_callins_spec.lua
@@ -58,11 +58,36 @@ for _, base in ipairs(BASES) do
function gh:UpdateCallIn(name) end
function gh:GameFramePost(frameNum) end
- local env = setmetatable({
+ -- The synced setup includes unit_idle_states, which reads CMD at its own
+ -- load, so the include has to land in here. It is specced on its own in
+ -- unit_idle_states_spec; these views only need it to load without error.
+ local env
+ env = setmetatable({
gadgetHandler = gh,
- Script = { GetSynced = function() return true end },
+ Script = {
+ GetSynced = function()
+ return true
+ end,
+ },
+ Spring = setmetatable({
+ IsDevLuaEnabled = function()
+ return false
+ end,
+ }, { __index = Spring }),
+ CMD = { MOVE = 10, REPAIR = 40, OPT_INTERNAL = 8 },
+ VFS = setmetatable({
+ Include = function(path)
+ local included = assert(loadfile(path))
+ setfenv(included, env)
+ return included()
+ end,
+ }, { __index = VFS }),
tracy = { ZoneBeginN = function() end, ZoneEnd = function() end },
- table = setmetatable({ new = function() return {} end }, { __index = table }),
+ table = setmetatable({
+ new = function()
+ return {}
+ end,
+ }, { __index = table }),
}, { __index = _G })
local chunk = assert(loadfile(MODULE_PATH))
@@ -242,7 +267,11 @@ for _, base in ipairs(BASES) do
end)
it("leaves no marks behind a throwing subscriber", function()
- local thrower = { [base .. "Post"] = function() error("boom") end }
+ local thrower = {
+ [base .. "Post"] = function()
+ error("boom")
+ end,
+ }
subscribe(thrower, "Post")
mark(101, 0.1)
postSeen, totalSeen = {}, {}
diff --git a/types/SyntheticCallins.lua b/types/SyntheticCallins.lua
index db941a451f1..9d5ea52eaae 100644
--- a/types/SyntheticCallins.lua
+++ b/types/SyntheticCallins.lua
@@ -3,13 +3,15 @@
---Call-ins that BAR invents and dispatches from luarules/gadgets.lua.
---
---The summary call-ins report a frame's events once, after the frame. They run
----only when something happened, and each event runs the layer stack in turn, as
----an engine-driven callin does.
+---only when something happened, and each event runs the layer stack in order.
---
---Each summary base has a Post and a Total. Both report the same objects in the
---same order, and Total adds the sum of each object's steps. We accumulate the
---sums only while a Total has subscribers, and Post is the cheaper call-in when
---we do not read them.
+---
+---A transition base has only a Post, and reports an object once per change in
+---its tracked state, rather than once per frame in which it saw an event.
---@class SyntheticCallins
---
---
@@ -71,3 +73,14 @@
---Accumulate: g:AllowFeatureBuildStep, GG.AccumulateFeatureBuildStep.
---Dispatch: g:GameFramePost.
---@field FeatureBuildStepTotal? fun(self, featureID: integer, part: number)
+---
+---
+---Runs for every unit that ran out of work, or that found some again.
+---Includes exhausted queues, factory queues, and the "idle tasks" that
+---units carry out on their own, which do not count as positive work.
+---
+---`idled` is `true` when the unit ran out of work, `false` when it found some.
+---
+---Mark: g:UnitIdle, g:UnitCommand, g:UnitTaken, g:UnitDestroyed.
+---Dispatch: g:GameFramePost.
+---@field UnitIdlePost? fun(self, unitID: integer, idled: boolean)