From 2fda6fb9bc6ec7adbe07e08d79bae1a7ad4eed45 Mon Sep 17 00:00:00 2001 From: Floris Date: Wed, 26 Aug 2026 13:22:30 +0200 Subject: [PATCH 1/8] sensor range radar preview: tweak (#8917) --- .../sensor_ranges_radar_preview.vert.glsl | 6 ++++-- .../gui_sensor_ranges_radar_preview.lua | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl index b7c3fe0aca5..ff9e9e6eff1 100644 --- a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl +++ b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl @@ -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 @@ -116,11 +118,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_sensor_ranges_radar_preview.lua b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua index ce4f54eec6e..ea962edda89 100644 --- a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua +++ b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua @@ -28,8 +28,8 @@ end ------------------------------------------------------------------------------------------------ -- Tunables -local CUBE_SPACING = 16 -- elmos between cube centers; must divide the radar cell size -local CUBE_WIDTH = 5.5 -- elmos +local CUBE_SPACING = 13 -- elmos between cube centers; must divide the radar cell size +local CUBE_WIDTH = 4.8 -- elmos 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 = { @@ -61,19 +61,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.4, + LINE_ALPHA = 0.4, -- opacity of the cube edge lines } -- Engine radar model (rts/Sim/Misc/LosHandler.cpp, LosMap.cpp) From a86dec3a13e5c5149b6e1579c5936abd3536defa Mon Sep 17 00:00:00 2001 From: Floris Date: Wed, 26 Aug 2026 15:33:08 +0200 Subject: [PATCH 2/8] sensor radar ranges preview: handle different radarMipLevel values better (#8919) --- .../sensor_ranges_radar_preview.vert.glsl | 11 +-- .../gui_sensor_ranges_radar_preview.lua | 70 +++++++++++-------- 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl b/luaui/Shaders/sensor_ranges_radar_preview.vert.glsl index ff9e9e6eff1..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; @@ -66,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 @@ -74,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 diff --git a/luaui/Widgets/gui_sensor_ranges_radar_preview.lua b/luaui/Widgets/gui_sensor_ranges_radar_preview.lua index ea962edda89..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 = 13 -- elmos between cube centers; must divide the radar cell size -local CUBE_WIDTH = 4.8 -- 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) @@ -74,8 +78,8 @@ local shaderConfig = { 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.4, - LINE_ALPHA = 0.4, -- 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) @@ -91,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 @@ -131,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 } @@ -217,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 }, @@ -424,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), @@ -618,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 @@ -712,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() @@ -771,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) @@ -788,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 From 8d395c37700b93e115b630a105b40043f3bda84a Mon Sep 17 00:00:00 2001 From: Floris Date: Wed, 26 Aug 2026 15:33:21 +0200 Subject: [PATCH 3/8] modrules: more detailed radarMipLevel (3->2) (#8920) --- gamedata/modrules.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. }, }, From 7b044f73a2dc5d7c29c06a95eb7fdefa69fefaed Mon Sep 17 00:00:00 2001 From: Rysica <309933144+Rysicaa@users.noreply.github.com> Date: Wed, 26 Aug 2026 06:37:11 -0700 Subject: [PATCH 4/8] Fix ruin geo spawn snap so they are upgradable (#8912) Fixes captured ruins buildings being impossible to upgrade due to our lock-and-key yardmap hack requiring both the upgraded and upgradee buildings to snap together. Spawned ruins are all buildings so now are snapped to a grid based on their size. Odd-sized buildings (like 5x5 geos) use the odd grid (8-elmo offset) in snap. Even sided buildings (like mex) use the even grid (0 elmo offset). --- luarules/gadgets/ai_ruins.lua | 58 ++++++++++++++++------------------- 1 file changed, 27 insertions(+), 31 deletions(-) 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) From cb68db780019aacfec617f8cf7af5526ad188940 Mon Sep 17 00:00:00 2001 From: Floris Date: Wed, 26 Aug 2026 15:57:37 +0200 Subject: [PATCH 5/8] buildsquare: show simplified buildsquare when placement is valid (#8921) --- luaui/Widgets/gui_buildsquare_gl4.lua | 561 +++++++++++++++++++++----- 1 file changed, 460 insertions(+), 101 deletions(-) diff --git a/luaui/Widgets/gui_buildsquare_gl4.lua b/luaui/Widgets/gui_buildsquare_gl4.lua index f954241e304..5074e89a631 100644 --- a/luaui/Widgets/gui_buildsquare_gl4.lua +++ b/luaui/Widgets/gui_buildsquare_gl4.lua @@ -61,7 +61,6 @@ 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 spGetTimer = Spring.GetTimer @@ -93,22 +92,24 @@ 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 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 +176,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 @@ -417,12 +427,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 +448,7 @@ local function collectPreview( renderCache.batchExtendedCells = extendedCells renderCache.batchCellScale = cellScale renderCache.batchSimplified = simplified + renderCache.batchMerged = merged end end @@ -487,8 +500,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 +540,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 +692,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 +708,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 +721,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 +733,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 +748,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 +840,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 +931,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 +987,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 +1029,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 +1042,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 +1229,191 @@ 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 + 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() @@ -1159,9 +1446,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 +1459,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 +1478,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 +1508,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 +1546,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 +1577,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 +1614,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 +1714,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 +1734,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 +1768,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 @@ -1585,11 +1882,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 +1928,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 +2006,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 From ee47fc067cd3657ad425d56f229bf6397a0fe32a Mon Sep 17 00:00:00 2001 From: Floris Date: Wed, 26 Aug 2026 16:25:50 +0200 Subject: [PATCH 6/8] =?UTF-8?q?buildsquare:=20placed=20buildsquare=20at=20?= =?UTF-8?q?mex/geo=20snap=20position=20instead=20of=20c=E2=80=A6=20(#8923)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- luaui/Widgets/gui_buildsquare_gl4.lua | 87 ++++++++++++++++++++------- 1 file changed, 66 insertions(+), 21 deletions(-) diff --git a/luaui/Widgets/gui_buildsquare_gl4.lua b/luaui/Widgets/gui_buildsquare_gl4.lua index 5074e89a631..e9a4a5b6f05 100644 --- a/luaui/Widgets/gui_buildsquare_gl4.lua +++ b/luaui/Widgets/gui_buildsquare_gl4.lua @@ -63,6 +63,7 @@ local spGetMouseState = Spring.GetMouseState local spTraceScreenRay = Spring.TraceScreenRay 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,6 +94,7 @@ 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 @@ -204,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 @@ -1414,6 +1416,64 @@ local function buildMergedFootprintRects(rectData, statuses, footprint, queuedFo 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() @@ -1421,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 @@ -1818,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 From f6cca2b1f53c24b11f4fbf85437454695201f2eb Mon Sep 17 00:00:00 2001 From: PrivacyIsARight Date: Wed, 26 Aug 2026 10:26:30 -0400 Subject: [PATCH 7/8] Fix exclusions typos (#8918) Followup on https://github.com/beyond-all-reason/Beyond-All-Reason/pull/8893 which is a followup on https://github.com/beyond-all-reason/Beyond-All-Reason/pull/8472 --- luarules/gadgets/cmd_dev_helpers.lua | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 From 96016330d5fbbeacf2a20d7d2c173d4bbdc6ca25 Mon Sep 17 00:00:00 2001 From: efrec Date: Wed, 26 Aug 2026 10:36:32 -0400 Subject: [PATCH 8/8] callin: post-frame summary for UnitIdle (#8888) ### Work done The engine's `UnitIdle` fires only when a command queue drains through `FinishCommand` or an explicit Stop, so it misses many of the ways a unit can run out of work and never fires for any unit that never had a command. This adds `UnitIdlePost`, a custom callin that reads the unit's queue directly and once post-frame, reporting only those whose idle state actually changed. The "idle tasks" a unit gives itself are not "work tasks", so units with only those tasks in their command queue count as idle. The new callin is only in synced code. The new idle module, unit_idle_states, is in the shared modules folder so can be used in widgets later. The synthetic callins code gets a new per-frame view, the transition view, which only fires when states change rather than acting like a plain mark, so would ignore A->B->A for example. --- luarules/callins/synthetic_callins.lua | 167 ++++++++++++++++++----- luarules/gadgets.lua | 25 +++- modules/unit_idle_states.lua | 69 ++++++++++ spec/luarules/synthetic_callins_spec.lua | 37 ++++- types/SyntheticCallins.lua | 17 ++- 5 files changed, 273 insertions(+), 42 deletions(-) create mode 100644 modules/unit_idle_states.lua 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/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)