Version:
2.0.0(Beta) Milestone:11
Target:gamemd.exe— Yuri's Revenge 1.001
Last Updated:2026-09-21(Extras-2 sweep; no version bump, no behavior change)
This document is the reference for the currently implemented LuaAPI interface.
⚠️ Important: This file documents implemented bindings. Planned or experimental systems must not be presented as stable API.
LuaAPI exposes native Yuri's Revenge objects through Lua namespaces and userdata bindings.
| Namespace / Object | Purpose |
|---|---|
House |
Access to player/house objects and economy |
World |
Global unit/building queries and map queries |
game |
Lower-level map/event-hook diagnostics |
Engine |
Engine/HUD functions |
Game |
Currently exposes the debug HUD text helper |
Techno object |
Validated units, infantry, aircraft, and buildings |
LuaAPI is designed around a native C++ safety layer with Lua controlling gameplay behavior.
🧩 Lua Gameplay Framework — On top of the native bindings below, LuaAPI ships a small Lua-side gameplay framework (
scripts/framework/): an EventBus, frame-based Timer, Query helpers, a Task primitive, and a UnitController. It composes the native methods and adds no native bindings. Seedocs/FRAMEWORK.md.
Engine objects are represented by native pointers wrapped in Lua userdata. Objects can become invalid when destroyed or when the game session changes.
Bindings therefore validate native objects before using them. Lua scripts should still treat engine objects as short-lived references.
A safe pattern is:
local units = World.GetUnits()
local unit = units[1]
if unit and unit:IsAlive() then
local hp = unit:GetHealth()
end
⚠️ IsAlive()is a validity/liveness check at the time of the call. It does not make a previously stored pointer permanently safe.
The following methods are registered on the LuaAPI.Techno userdata.
Returns the object's INI type identifier.
local typeName = unit:GetTypeName()
-- "DRED", "APOC", "E1", etc.Returns: string or no Lua value when validation fails.
Returns current health.
local hp = unit:GetHealth()Returns: number.
Returns the object's configured maximum health.
local maxHp = unit:GetMaxHealth()Returns: number.
Returns the house that owns the object.
local owner = unit:GetOwner()Returns: House userdata or nil.
Returns the object's position in map-cell coordinates.
local pos = unit:GetPosition()
print(pos.x, pos.y, pos.z)Returns: table containing x, y, and z.
Coordinates are converted from the engine's 256-lepton cell representation.
Checks whether the object passes the native liveness validation.
if unit:IsAlive() then
-- object is currently usable
endReturns: boolean.
Returns Euclidean distance between two techno objects in map cells.
local distance = unit:GetDistanceTo(enemy)Returns: number, or nil when the second object is invalid.
Returns the engine-wide unique object ID.
local id = unit:GetId()Returns: number.
Returns the native object category.
local kind = unit:GetKind()Possible values include:
building
unit
infantry
aircraft
other
Returns: string.
Orders a mobile techno to scatter from its current position, optionally using a supplied cell position.
unit:Scatter()
unit:Scatter(100, 100)Returns: no value.
Only mobile FootClass-derived objects can perform the movement operation.
Queues a movement order to the specified map cell.
local ok = unit:MoveTo(100, 120)Returns: boolean.
Queues the native Hunt mission for a mobile unit.
unit:Hunt()Returns: no value.
Orders a mobile unit to attack a specific target object.
local ok = unit:Attack(enemy)Uses the native SetTarget + QueueMission(Attack) path (not the
player-click path). Only works for mobile FootClass-derived objects;
returns false for buildings and invalid targets. The assigned target
can be read back with unit:GetTarget().
Returns: boolean.
Checks whether a mobile unit is currently in Guard, Stop, or Sleep mission state.
if unit:IsIdle() then
-- idle
endReturns: boolean.
Checks whether the object's current mission is Attack.
if unit:IsAttacking() then
-- attacking
endReturns: boolean.
Reads the object's current native mission (TechnoClass::CurrentMission).
local mission = unit:GetMission()
-- "Guard", "Move", "Attack", "Stop", ... (or a numeric code when no name resolves)Returns: string or number (nil when validation fails).
Checks whether the object is currently on the ground (landed).
if unit:IsOnFloor() then
-- unit is on the ground
endFor aircraft: returns true when landed on a helipad or airfield; false when airborne.
For ground units: typically always true.
Returns: boolean.
Checks whether the object is currently airborne.
if unit:IsInAir() then
-- unit is flying
endFor aircraft: returns true when flying; false when landed.
For ground units: typically always false.
Returns: boolean.
Checks whether an aircraft is currently in the landing descent phase.
if unit:IsLanding() then
-- aircraft is descending to land
endOnly meaningful for AircraftClass with FlyLocomotionClass. Returns false for non-aircraft.
Returns: boolean.
Orders an aircraft to return to the nearest airfield/helipad and land (native Mission::Return).
local ok = unit:Return()Only works for AircraftClass. Returns true if the mission was queued successfully.
Returns: boolean.
Returns the object's current native target when available.
local target = unit:GetTarget()Returns: a Techno object or nil.
Applies damage through the native ReceiveDamage pipeline when a suitable warhead is available.
local remainingHp = unit:TakeDamage(100, "TerrorBombWH")Parameters:
amount— positive damage amountwarhead— optional warhead ID
Returns: remaining health as number.
The implementation uses a fallback warhead chain when the requested warhead cannot be resolved.
⚠️ This is a real engine damage operation, not merely a Lua-side health assignment.
Temporarily disables a techno using native engine mechanisms.
unit:Disable(90)Parameters:
frames— duration in logical game frames
Buildings use their power/disabled state; mobile objects use the engine's paralysis mechanism. State is restored when the timer expires.
Sets health using a percent value (the implementation divides by 100).
unit:SetHealthRatio(35)Examples:
35 = 35%
100 = 100%
⚠️ Fractional 0–1 inputs do not work as fractions here: the value is truncated to an integer first, so1.0means ~1%, not 100%. Pass 0–100.
Returns: no value.
Attaches a particle system to the object.
unit:AttachParticleSystem("DamageSmokeSys")Parameters:
name— particle-system identifier
Returns: boolean.
Registers a draw-only bounty overlay (rectangle + label) for the unit,
drawn in the DrawAsVXL detour after the original draw call. Keyed by
UniqueID, never by pointer; expires by logical frame, explicit clear, or
session reset. Simulation state is untouched (CnCNet-safe).
unit:MarkBounty() -- green "BOUNTY", until cleared
unit:MarkBounty(0x00FF00, 4500)
unit:MarkBounty(0xFFD700, 300, "+$1800") -- gold "+$1800" for 5 secondsParameters:
color—COLORREF(default0x00FF00, green); converted internally to raw 5-6-5 for the rectangle, passed as-is to the label textdurationFrames—0/omitted = until clearedlabel— caption text (default"BOUNTY"); ASCII, capped at 23 characters, longer input is clipped. Non-printable bytes end the caption. Captions starting with"BOUNTY"render on a solid black background box (BEFORE style); all other captions render with a 1px black outline (AFTER style, e.g."+$1800"). No style flag on the API by design
Returns: boolean (false for non-Unit kinds — the detour covers
vehicles/ships only — and invalid objects).
Live consumer:
bounty_hunterv2.1+ marks the killer unit with a gold"+$<reward>"caption so the earner sees the exact payout.
Removes the unit's bounty overlay registration.
unit:ClearBountyMark()Returns: no value.
Sells a building through the native ObjectClass::Sell path (virtual
dispatch, no hardcoded address — the same call the engine makes for the
player sell action). Buildings only; the engine honors per-type
Unsellable and settles occupants/repair state itself.
local sold = base:Sell()Returns: boolean (false for non-buildings and invalid objects).
⚠️ Live consumer: SmartAI surrender liquidation (sells the surrendered house's base so the surrender is visible). Refund follows standard engine rules.
The native
SubTurretManagersidecar (src/sub_turret.*), its Lua bindings (AddSubTurret/GetSubTurretCount/GetSubTurret/SetSubTurretTarget/FireSubTurret/ClearSubTurrets/SetSplitTargets/FireSplitSalvo), the spawned-missile decoupling, theBulletHookDetonate hook (src/bullet_hook.*), theEventHookspawner-target module (src/event_hook.*), andFireProjectilewere removed — zero live consumers inscripts/, per-frame full-array sweep and per-detonation hook overhead on the game thread. History preserved in git and inPROJECT/ROADMAP.md(Milestone 10).IronCurtain(standalone, synchronous) is kept.
Returns the current human player's house.
local player = House.GetPlayer()Returns: House userdata or nil.
Returns the number of houses in the engine house array.
local count = House.GetCount()Returns: number.
Returns a house by engine-array index.
local house = House.GetByIndex(0)Returns: House userdata or nil.
Indexes are 0-based.
Returns current available money.
local credits = house:GetCredits()Returns: number.
Sets the house's available credits by applying the required transaction delta.
house:SetCredits(5000)Parameters:
amount— target credit balance
Returns: no value.
Adds or subtracts credits.
house:AddCredits(500)
house:AddCredits(-100)Parameters:
amount— credit delta
Returns: no value.
Returns total power production.
local output = house:GetPowerOutput()Returns: number.
Returns total power consumption.
local drain = house:GetPowerDrain()Returns: number.
Returns the engine house ID/name.
local name = house:GetName()Returns: string.
Returns this house's AI difficulty level as selected in the game lobby
(read-only). One of "easy", "normal", or "hard". The engine stores the
value reversed (HouseClass::AIDifficulty: hard = 0, normal = 1, easy = 2);
this binding normalizes it, so Lua code never sees the raw enum.
local level = house:GetAIDifficulty()
if level == "hard" then
-- pick aggressive runtime decisions
endReturns: string ("easy" | "normal" | "hard"), or nil when the read
fails (callers should fall back to a default instead of treating a failure as
a difficulty). Read-only: Lua cannot change the engine difficulty through this
binding.
Checks whether the house is controlled by a human.
if house:IsHuman() then
-- human-controlled
endReturns: boolean.
Checks alliance status between two houses.
if house:IsAlliedWith(enemyHouse) then
-- allied
endReturns: boolean.
Development/gameplay helper for creating units.
local created = player:SpawnUnit(
"APOC",
5,
100,
100,
0,
false,
"hunt"
)| Parameter | Description |
|---|---|
typeId |
INI unit type identifier |
count |
Number of units; defaults to 1 |
x |
X map cell |
y |
Y map cell |
facing |
Direction 0–255; defaults to 0 |
force |
Force-spawn flag; defaults to false |
action |
Optional action; "hunt" queues Hunt |
When normal spawning is used, the implementation searches for a nearby valid cell within its configured radius. The current implementation uses a radius of 3 cells for the fallback search.
Returns: number — successfully created units.
Returns building objects from the engine building array.
local buildings = World.GetBuildings()Returns: Lua table of Techno objects.
Returns mobile technos: vehicles, infantry, and aircraft.
local units = World.GetUnits()Returns: Lua table of Techno objects.
Returns every supported techno in the engine techno array, including buildings.
local objects = World.GetAllUnits()Returns: Lua table of validated Techno objects.
💡 Use this for global scans. It does not depend on an arbitrary spatial radius.
Returns vanilla AI attack-wave teams (TeamClass::Array) as plain data
tables — read-only visibility, no userdata, no engine writes (SmartAI M1-A).
Each entry:
local teams = World.GetAITeams()
-- teams[1] = {
-- index = 0, -- scan-local TeamClass::Array position (NOT
-- -- stable across scans: TeamClass instances are
-- -- never assigned engine UniqueIDs — live-verified
-- -- 2026-09-23. Track via teamtype + creationFrame
-- -- + member set instead)
-- teamtype = "0A8B4CAX", -- TeamType id (skirmish AI uses generated ids)
-- owner = "Russians", -- house id (same space as house:GetName())
-- targetHouse = "Germans", -- or nil (nil until the engine assigns one)
-- scriptMission = 2, -- current script step index; -1/nil = no mission
-- totalObjects = 3,
-- isFullStrength = true, isUnderStrength = false, isHasBeen = true,
-- creationFrame = 5400,
-- members = { 101, 102 }, -- member UniqueIDs (cross-reference with
-- -- World.GetUnits() snapshot ids)
-- }Returns: Lua table (possibly empty, e.g. no teams yet built).
Read-only team visibility only: Lua cannot create, recruit, retarget, or disband vanilla teams through this API. Orders to members use the regular
MoveTo/Attackprimitives (vanilla may re-task them — no order lease, see CnCNet/Multiplayer notes).
Returns the coordinates of a map waypoint, read live from the scenario's
waypoint table (ScenarioClass, engine range [0..701]).
local pos = World.GetWaypoint(5)
if pos then
print(pos.x, pos.y)
endReturns: position table {x, y} in map-cell coordinates, or nil
when the id is out of range (negative or >= 702), the waypoint is not
defined on the current map, or no scenario is loaded.
Waypoint IDs are 0-based engine indices. The lookup reads live scenario state on every call (nothing cached), so it is safe across match resets.
Returns techno objects within a specified radius in map cells.
local units = World.GetUnitsInRadius(100, 100, 15)Parameters:
x— center X celly— center Y cellradius— radius in cells
Returns: Lua table of matching Techno objects.
RA2 uses 256 leptons per cell. Native squared-distance calculations must use sufficiently wide arithmetic for large radii.
For whole-map searches, prefer World.GetAllUnits() instead of using an unnecessarily large radius.
The lowercase game namespace is a separate low-level/diagnostic namespace retained by the current implementation.
Legacy/global form of the waypoint query (same implementation and
contract as World.GetWaypoint — dot-call only, like all plain
namespace functions).
local pos = game.GetWaypoint(5)Returns: position table {x, y} or nil (same rules as above).
Legacy/global form of the spatial unit query.
local units = game.GetUnitsInRadius(100, 100, 15)Returns: Lua table.
game:GetEventHookOverrideCount()/game:ClearEventHookOverrides()were removed 2026-09-21 with theEventHookmodule.
Displays a message through the game's message-list system.
Engine.PrintMessage("Hello, Commander!")Parameters:
text— UTF-8 message string
Returns: no value.
⚠️ The current native implementation does not expose acolorIndexargument.
Clears every bounty overlay registration (global reset; also runs automatically on session reset).
Engine.ClearBountyMarks()Returns: no value.
Crash-isolation diagnostic switch: 0=off (registry live, no pixels),
1=rectangle only, 2=text only, 3=full (default).
Engine.SetBountyDrawMode(1)Returns: boolean (false for out-of-range input).
Returns the current text used by the debug-console HUD indicator.
local text = Game.GetDebugHudText()Returns: string.
This is a development/debug helper. It is not the logical frame API.
The following capabilities exist in the current build but were previously
absent from this reference. They are implemented and source-verified
(src/lua_engine.cpp, src/bindings_techno.cpp, src/weapon_override.cpp);
unless a live-game consumer is on record they are graded UNVERIFIED LIVE, and
all of them are dev/diagnostic helpers first — treat stability and exact
behavior as experimental.
Checks whether a weapon ID exists in rules (WeaponTypeClass::Find). SEH-wrapped;
returns false on error or empty id.
if Engine.WeaponExists("ZeusTrail") then
-- safe to reference the weapon
endRaise, lower or read the engine's own per-cell radiation level
(CellClass::RadLevel, third_party/YRpp/CellClass.h:444). The accessors are
the game's: RadLevel_Increase / RadLevel_Decrease (CellClass.h:249,252)
and IsRadiated (CellClass.h:243). delta > 0 raises, delta < 0 lowers.
nil from the getters means off-map or an unresolved cell; false from the
setter means the same, or delta == 0.
This is not a rendering hook, and that is the point. The green irradiated
ground in RA2/YR is drawn by the engine from this scalar, and the engine decays
it on its own. The Radiation Control Center and the Prism Tower raise it
through these same accessors, so a scripted hazard stays consistent with a real
Rad Site instead of drifting from it. A weather effect that greens the ground
costs two engine calls per cell and touches no draw path — which matters,
because a render detour in this codebase has already produced visible
artefacts (src/dynamic_fow_poc.cpp).
-- Desolator-style irradiated patch, 10x10 cells
for y = 50, 60 do
for x = 50, 60 do
World.SetCellRadLevel(x, y, 0.35)
end
end
if World.IsCellRadiated(55, 55) then ... endGameplay caveat. A cell with a high
RadLevelalso makes the engine damage infantry standing on it. Raising it therefore adds damage that no Lua-side accounting attributes to anyone, and a mod that refunds "damage it dealt" will under-refund. Treat the level as a tunable and readGetCellRadLevelto observe what the engine actually holds rather than assuming. To clear ground, subtract exactly the level the engine reports (SetCellRadLevel(x, y, -GetCellRadLevel(x, y))) — a fixed delta cannot cancel a level accumulated over many ticks.
Checks whether a warhead ID exists in the loaded rules
(WarheadTypeClass::Find). SEH-wrapped; returns false on error or empty id.
Mirrors Engine.WeaponExists — note that WeaponExists only looks up
weapons and cannot answer this question.
Added because obj:TakeDamage(amount, warheadName) resolves the name through a
silent fallback chain (named → TerrorBombWH → DemobombWH →
Rules->C4Warhead). A name that is not in the loaded rules therefore does not
error — it quietly becomes TerrorBombWH, whose InfDeath=4 is Flames. A mod
that wants a radiation death animation and misspells the warhead gets infantry
burning to death with nothing in its own output to indicate the name was wrong.
if Engine.WarheadExists("RadBeamWarhead") then
unit:TakeDamage(40, "RadBeamWarhead") -- InfDeath=7, Nuked
endRelated: the infantry death animation is selected by the warhead's
InfDeath field, not by the caller. Values are
0 None, 1 Die1, 2 Die2, 3 Explode, 4 Flames, 5 Electro, 6 HeadPop, 7 Nuked, 8 Virus, 9 Mutate, 10 Brute. Warheads observed carrying InfDeath=7 (the
radiation/nuke look) include NUKE, RadSite, RadBeamWarhead and
RadEruptionWarhead; which of them exist depends on the loaded ruleset, hence
the existence check.
Mutes/unmutes LuaAPI HUD output globally (affects Engine.PrintMessage
display); state is also logged. Use to silence a mod's HUD without touching
the game's own messages.
Engine.SetHudMuted(true)
if Engine.IsHudMuted() then ... endInstalls a GetPrimaryWeapon hook (installed at DLL init; degrades to vanilla
behavior with a warning if the hook fails).
WeaponOverride.Set(typeId, vetLevel, weaponId) -- -> boolean
WeaponOverride.Get(typeId, vetLevel) -- -> string | nil
WeaponOverride.Clear() -- clear all
WeaponOverride.Clear(typeId) -- clear one type
WeaponOverride.Clear(typeId, vetLevel) -- clear one entryPer-session state; cleared on session reset. Veteran level naming (Rookie/
Veteran/Elite-class keys) follows the implementation in
src/weapon_override.cpp — verify the exact key form in source before use.
Returns airborne technos (same object contract as World.GetUnits()).
Returns the player's currently selected objects (reads
ObjectClass::CurrentObjects). GetSelectedUnits returns UnitClass objects;
GetSelectedTechnos the general set. Consumers must re-validate per use
(IsAlive) — selection contents change with player input at any moment.
Used live by the dynamic_objective_defense showcase (Gate 12.2).
These entries completed the docs-vs-source sweep for Gate 1 (2026-09-20). The 2026-09-21 sweep below reopens the list: it found ~25 further implemented-but-undocumented bindings. If you find an implemented binding missing from this reference, record it rather than relying on it silently.
Source of truth: src/bindings_techno.cpp (kTechnoMethods,
RegisterTechnoBindings), src/lua_engine.cpp (CreateEngine),
src/barrel_pitch.cpp (RegisterBindings), src/bindings_production.cpp.
Grading: implemented + source-verified; UNVERIFIED LIVE unless a live
consumer is named. Dev/diagnostic framing — stability not promised.
local vet = unit:GetVeterancy() -- "rookie" | "veteran" | "elite" (pcall-guarded by mods)
local cost = unit:GetCost() -- rules price, number (reward math base)local ammo = unit:GetAmmo()
unit:SetAmmo(n)
local speed = unit:GetBaseSpeed()
unit:SetSpeedPercent(pct)
local sight = unit:GetSight() -- INI Sight= in cells (0 on failure); == final
-- See radius for ground units (SHROUD_RCA §2.4)unit:Stop() -- native Stop mission; the mission name checked by IsIdle
unit:Unload() -- transport unload primitive
unit:Scatter() -- documented above; listed here for completenesslocal loc = unit:GetHarvestLocation() -- harvest anchor query
unit:HarvestAt(x, y) -- harvest order primitiveunit:Deploy() -- returns boolean
unit:TryToDeploy()
unit:Undeploy()
unit:CanDeployNow() -- -> boolean (gate before Deploy)
unit:IsDeployed() -- -> boolean
unit:IsDeploying() -- -> boolean
unit:IsUndeploying() -- -> booleanunit:IronCurtain(frames) -- invulnerability primitive (standalone, kept)
FireProjectileremoved 2026-09-21 withBulletHook(was its onlyRegistercaller). The whole M10 sub-turret family (AddSubTurret/SetSplitTargets/FireSplitSalvo/ …) removed the same day — see §Sub-Turret API.
Edge-triggered (pressed-now AND not-pressed-before), per
src/bindings_techno.cpp:1678. State in g_keyPrevState[256].
if Input.WasKeyPressed(0x54) then -- T, once per press
endRegistered in src/bindings_production.cpp:120-124; absent from this
reference until now. PROJECT/GATES.md Gate 2 records
AI.QueueUnit as accepted-but-no-output live (2 runs) — BLOCKED.
AI.QueueUnit(house, "APOC") -- -> boolean (accepted, not produced live)
AI.CountUnit(house, "APOC") -- -> integer (factory queue counts)Engine.GetBarrelPitchOverride(unitId) -- -> number | nil (manual pitch read-back)
Engine.GetBarrelPitchAuto(unitId) -- -> number, live sample (used by barrel_elevation_diag)
Engine.ClearBarrelPitchOverride(unitId) -- clear one manual override
Engine.ClearAllBarrelPitchOverrides() -- clear all manual overrides
Engine.SetPersistentBarrelPitch(typeId, deg) -- persistent type-field path (M16 static experiment)
Engine.ClearPersistentBarrelPitch(typeId)
Engine.version -- string field on the Engine table (project version, `"2.0.0"`)LuaAPI uses a mod-table callback model for the per-frame gameplay callback. Mods return a table and the loader dispatches its Update method.
A typical mod has the form:
local MyMod = {}
function MyMod.Update(frame)
-- logical-frame gameplay logic
end
return MyModEngine event callbacks (OnPreDamage, OnScenarioStart,
OnUnitDestroyed) are looked up as globals, not mod-table methods:
defining MyMod.OnPreDamage alone never fires. Define a global
function instead.
⚠️ Status: the engine collects the globalOnPreDamagereference every frame but currently never invokes it with damage arguments — noReceiveDamagehook is installed in this build. Live engine damage does NOT reach Lua. The contract below describes the intended design for when interception is wired, not working behavior. Do not build a reactive-armor mechanic on this callback yet.
Intended form:
function OnPreDamage(attacker, target, damage, dmgType, frame, subc)
if dmgType == "energy" then
return damage * 0.5
end
return nil
endIntended return semantics (unverified live):
nil— leave damage unchanged- non-negative number — replace the damage value
0— cancel the damage
⚠️ Never return negative damage. Avoid recursively generating additional damage from inside the callback without a re-entrancy guard.
Dispatched as a global once when the logical frame counter reaches 1.
function OnScenarioStart()
-- post-scenario initialization
end
⚠️ Do not assume this callback runs when a saved game is loaded. Runtime systems that require persistent state must account for the savegame lifecycle.
Defined as a global, but in the current build it is never invoked: the
C++ dispatch block (lua_engine.cpp, Gate 7.2) only fires when callback refs
are already queued, and nothing ever queues them — the branch is unreachable.
There is currently no destruction detection behind it. Do not build
death-reactive logic on this callback; use ID-diff death detection instead.
When (and if) a native death hook lands, the intended form is:
function OnUnitDestroyed(victim, killer)
if victim == nil then return end
-- not dispatched in the current build; intended form only
endRuns on the game's logical-frame dispatch path.
function MyMod.Update(frame)
if frame % 30 ~= 0 then
return
end
-- periodic logic
end⏱️ Gameplay timing should use the logical game frame rather than render FPS.
Global dispatcher owned by the loader (scripts/init.lua), not by mods.
The C++ frame handler calls it once per logical frame with the current
engine frame number; it fans out to every loaded mod's Update(frame)
inside per-mod error isolation.
Mods must define Update on their returned table — never replace the
global OnTick (the loader defines it after loading mods, so a
mod-defined OnTick would be overwritten and would break dispatch for
every other mod).
Global development callback.
function OnDebugCommand(text)
-- parse development command
endUnlike normal mod callbacks, OnDebugCommand is global.
LuaAPI's main loop hook dispatches gameplay logic only when Unsorted::CurrentFrame changes.
Conceptually:
Render / engine calls
↓
MainLoop hook
↓
Current logical frame changed?
↓
yes
↓
LuaAPI dispatch
This prevents the same logical-frame gameplay state from being advanced multiple times merely because the process executes the main loop at a different render rate.
CnCNet may launch gamemd-spawn.exe; integrations must therefore resolve the actual game module/process rather than assuming the executable name is always gamemd.exe.
- C++ manages native state; Lua controls gameplay behavior.
- Validate engine-backed objects before use.
- Do not retain stale native pointers across destruction or session transitions.
- Defer container cleanup when iteration can trigger object destruction.
- Invalidate references to destroyed targets immediately.
- Account for savegame lifecycle; scenario-start initialization is not sufficient for loaded saves.
- Use 64-bit or floating-point arithmetic where squared spatial values can exceed 32-bit range.
- Drive gameplay timing from logical frames, not render FPS.
- Treat hook/signature mismatches as compatibility conditions to investigate, not automatically as fatal errors.
Dynamic barrel pitch/elevation for voxel-turret units. The automatic mode is
always on in the diagnostic mod: every unit with a voxel turret gets a
distance-based pitch (8° at 4 cells → 55° at 14 cells) via a draw-time
FireAngle swap in a UnitClass::DrawAsVXL detour (src/barrel_pitch.cpp).
Draw-only, client-local, simulation untouched — CnCNet-safe.
⚠️ Asset requirement (proven 2026-09-20): stock YR vehicles fuse turret and barrel into one voxel, andFireAngleonly rotates a SEPARATE barrel voxel (ModEnc; community-confirmed). On stock units the mechanism applies but cannot show visible movement. Visible elevation requires an asset with a separate barrel voxel and/or a multi-frame turret HVA (pose frames).
Manual pitch for one unit. 0 horizontal, 90 straight up, negative allowed
(below horizontal), clamped to [-90, 90].
Engine.SetBarrelPitchOverride(unitId, 35.0)Returns: true.
Per-unit AUTO mode: pitch computed natively each draw call from the live target distance; without a target the unit draws vanilla.
Engine.SetBarrelPitchAuto(unitId, true)Returns: true.
Global AUTO mode (used by the diagnostic mod, no keys): every voxel-turret unit — player and AI alike — pitches by its live target distance.
Engine.SetBarrelPitchAutoAll(true)Returns: true.
Number of units that drew with an AUTO-computed pitch on recent frames.
local n = Engine.GetBarrelPitchAutoCount()Returns: number.
HVA pose-frame access on the turret voxel (the TS elevation mechanism).
SetTurretAnimFrame writes TurretAnimFrame; the engine wraps by
FrameCount. GetTurretAnimFrameCount reads
Type->TurretVoxel.HVA->FrameCount (0 for SHP turrets / no turret).
local count = unit:GetTurretAnimFrameCount()
if count > 1 then
unit:SetTurretAnimFrame(count - 1) -- high pitch pose
endReturns: frame number / applied frame number / frame-count number.
README.md— Project overview and installationdocs/FRAMEWORK.md— Lua Gameplay Framework reference (Milestone 14)docs/TUTORIAL.md— Beginner tutorialPROJECT/CAPABILITIES.md— Verified capabilities and case studiesPROJECT/ENGINEERING_LESSONS.md— Engineering lessons and debugging historyPROJECT/ROADMAP.md— Architecture roadmapPROJECT/CHANGELOG.md— Project history
These functions expose the engine's own fog-of-war state. Reads are
observation; the single write (SetCellShrouded) only removes information
(re-shroud), never grants vision — so it is not the "fake vision" that
FSM/FEASIBILITY_TRIAGE.md:268 puts out of scope (that clause is about
revealing). Live consumer: dynamic_fow (v0.5+).
Ground truth was established by static reverse engineering of gamemd.exe and is
written up in docs/research/SHROUD_RCA.md §§2–§6 (grade: STATIC VERIFIED) plus the
OpenTS cross-reference (§9: Cell_Shadow, Encroach_Shadow, sight formula).
In short: the engine decides "is this cell shrouded?"
with a single test on one bit of one field, and these bindings read exactly that.
Write path: re-shroud only, by project decision (2026-09-28). The original "no setter" stance below is preserved as history, but superseded: a proven live consumer (
dynamic_fow) needed explored ground to grow back, and OpenTS showed the lineage engine has its own regrow (Encroach_Shadow; disconnected in the YR fork —SHROUD_RCA.md§9.1 row 10). The reverse direction (revealing) is intentionally NOT exposed: revealing is free (units do it every frame) and exposing it would be a vision-granting write path.Original stance (2026-09-26, superseded): granting vision, or writing shroud so that explored ground grows back, is the "fake vision" that
FSM/FEASIBILITY_TRIAGE.md:268puts out of scope. A read is observation and is fine; manufacturing vision is not. — Kept because the reveal half is still out of scope; only re-shroud was approved, via the consumer + capability above.
Not owner-aware, and not able to be. The shroud bitfield is one per cell:
TechnoClass::Seewrites the cell'sAltFlagsregardless of which house asked. So on a two-human MP map these functions report the shared cell state, and you must not present them as "what house X currently sees".That said, this is a limit of these bindings, not proof that the engine has no per-house concept. YRpp documents house-scoped fog entry points that are simply not exposed here —
DisplayClass::RevealFogShroud(CellStruct*, HouseClass*, bool),DisplayClass::MapCellFoggedness(CellStruct*, HouseClass*),MapClass::Reveal(HouseClass*),MapClass::Reshroud(HouseClass*),HouseClass::ReshroudMap(), and a per-cellCellClass::FoggedObjectslist.World.GetFogStatetakes noHouseClassargument, so none of that is observable from Lua today. Seedocs/research/SHROUD_RCA.md§8.
Status: experimental / raw state inspection. These three functions are a direct read of
CellClassfields, not a curated "fog of war" API. They were built to measure the engine, and measurement produced results that invalidated part of their own original documentation (notablyvisible, below). UseshroudedandshroudFrame— those two are solid. Treat the rest as unverified.
Raw per-cell state, one cell per call.
local s = World.GetFogState(60, 80)
if s then
print(s.shrouded, s.shroudFrame)
endReturns a table, or nil when (x, y) is outside the map:
| Field | Type | Meaning |
|---|---|---|
shrouded |
boolean | true = AltFlags & 0x8 is clear. This is the authoritative "never explored" bit the whole game tests. |
visible |
boolean | true when AltFlags & 0x10 is set. This bit was originally documented as "currently inside some unit's sight radius". That reading is withdrawn — see the caveat below. |
centerRevealed |
boolean | Flags & 0x01 |
edgeRevealed |
boolean | Flags & 0x02 |
flagToShroud |
boolean | Flags & 0x20. Offset disputed — see below. |
fogged |
boolean | Flags & 0x40. Offset disputed — see below. |
shroudFrame |
integer | CellClass+0x120 as a signed byte: -2 fully occluded, -1 fully visible, 0..48 a shroud.shp / fog.shp frame |
fogFrame |
integer | CellClass+0x121, same encoding |
shroudCounter |
integer | CellClass+0x130: engine sight-source count. The ONLY authoritative "is anybody watching this cell" signal — use it, not visible/shroudFrame. Proven DEAD as a live signal (never > 0 across 50+ sweeps / 750 frames, 2026-09-28): YR's only IncreaseShroudCounter caller besides the never-taken bIncrease=1 branch sits in dead code (0x577C88, zero callers). TS lineage has no counters at all (SHROUD_RCA.md §9.1 row 11). Kept as a canary. |
gapsCovering |
integer | CellClass+0x134: counter clamp bound. Informational. |
AltFlags & 0x10 was interpreted as current visibility. Reverse engineering
found no instruction anywhere that clears only that bit: every writer in
CellClass+0x12C does or al, 0x18 (setting 0x8 and 0x10 together) and
every clearer does and …, ~0x18 (clearing both). On the paths examined, the
bit is therefore sticky, not per-frame.
Runtime agrees: across 9 380 probe calls in three matches, the combination
shrouded == false and visible == false was observed zero times. So
visible cannot currently be used to answer "is this cell lit right now", and
the classic three-state model UNEXPLORED → REMEMBERED → VISIBLE is not
confirmed by this binding.
shrouded == trueimplies the cell has never been seen by anyone, or was re-shrouded (byWorld.SetCellShrouded, e.g. thedynamic_fowmod — which clears exactly the explored bit, so the bit reads identically to never-explored). Onceshroudedflips tofalseit never flips back during a match unless something re-shrouds: the engine's own regrow path is disconnected in this fork (dead0x577C88,SHROUD_RCA.md§9.1 row 10), so in vanilla play expect 0 re-shrouds (0 in 213 fixed-origin consecutive scans, twice).
Do not treat these two fields as established:
| Field | This binding reads | YRpp declares |
|---|---|---|
flagToShroud |
Flags (+0x140) bit 0x20 |
AltFlags (+0x12C) FlagToShroud = 0x20 |
fogged |
Flags (+0x140) bit 0x40 |
AltFlags (+0x12C) Fogged = 0x400000 |
The offsets were not changed, because no runtime or disassembly evidence
gathered so far decides between the two readings — in particular, both fields
were false in 100% of live observations, so neither has been exercised. The
names may also be swapped between the two structures. Treat both as
unverified and re-derive from disassembly before relying on them.
Observed behaviour across three matches:
- All three of
-2,0..48and-1occur. 0..48values appeared only on cells that were alsovisible, withcenterRevealed == falseandedgeRevealed == true— i.e. at the edge of the current sight radius. The partial frames behave as a live fog/visibility gradient, not as a per-cell "remembered" memory.- Highest value ever observed was 34;
35..48were never produced. +0x120and+0x121matched in 773/773 calls across two matches, then diverged by one cell from frame 5100 in the third match and stayed diverged. They are computed by different code paths (GetOcclusion(fog=0)vsGetOcclusion(fog=1)), so they are not guaranteed equal.
None of this lets you tell "remembered" from "currently on the boundary" —
that needs owner-aware or renderer-side information this binding does not carry.
See docs/research/SHROUD_RCA.md §8.
Boolean-only form of GetFogState, named after the engine function it mirrors.
if World.IsLocationShrouded(60, 80) then
-- nobody has ever been here
endBulk read: one byte per cell, row-major from (x, y). This is the path an
overlay actually wants — a viewport is roughly 100×100 cells and 10 000 separate
Lua calls per redraw is not viable, so the result is a single string rather than
a table of 10 000 numbers.
local raw = World.GetFogRegion(50, 60, 33, 17)
if raw then
for i = 1, #raw do
local b = raw:byte(i)
local shrouded = (b & 0x01) ~= 0
local visible = (b & 0x02) ~= 0
end
endPacked bits per byte:
| Mask | Constant | Meaning |
|---|---|---|
0x01 |
shrouded | never explored (AltFlags & 0x8 clear) |
0x02 |
visible | AltFlags & 0x10 set — not a reliable "visible now", see the caveat above |
0x04 |
centerRevealed |
|
0x08 |
edgeRevealed |
|
0x10 |
flagToShroud |
offset disputed, see above |
0x20 |
fogged |
offset disputed, see above |
Returns nil for a region that leaves the map or exceeds 0x40000 cells, and
an empty string for a zero-sized region.
Determinism: every function here is a pure read of map state the engine already uses for its own visibility decisions, so it adds no nondeterminism and is safe in multiplayer.
Re-shrouds one explored cell: clears AltFlags 0x18 + Flags 0x01|0x02
(exactly the vanilla Reshroud combination), so the cell reads and renders
as genuinely-unexplored ground, and the engine re-reveals it by its own
See path when a unit returns. Never touches ShroudCounter/GapsCovering
or vision-granting state. SEH-wrapped; false off-map or on engine fault.
if World.SetCellShrouded(60, 80) then
-- explored ground grows back; a returning scout re-opens it
endReturns true on success. Live consumer: dynamic_fow v0.5+
(sight-geometry guard + combat grace; SHROUD_RCA.md §9 for the lineage).
Multiplayer: the write itself is deterministic, but mods that guard it by their own player's sight (like
dynamic_fow) diverge per client — single-player oriented, do not assume MP safety for guarded callers.
Posts one tactical dirty area covering the viewport if any
SetCellShrouded write happened since the last flush (otherwise no-op).
Westwood's own regrow (DisplayClass::Encroach_Shadow) ends with a full
tactical redraw; this is the YR-verified equivalent
(TacticalClass::RegisterDirtyArea, YRpp 0x6D2790). Call once per sweep
when writes occurred so fresh bits show without scrolling. SEH-wrapped.
if did > 0 and World.FlushShroudRedraw then
pcall(World.FlushShroudRedraw)
endBase sight radius in cells (TechnoTypeClass::Sight, INI Sight=). For
ground units this equals the final See radius
(radius = Sight * (0.01 * height + 1.0) — SHROUD_RCA.md §2.4, second-source
confirmed via OpenTS TechnoClass::Look, §9.1 row 7). Aircraft see farther
with height; Lua sees the base value. SEH-wrapped; 0 on validation failure.
local r = unit:GetSight() -- e.g. 7 for a GrizzlyDrives the pixel-source substitution at 0x69E740: a marked cell draws
SHP frame 15 (fully-occluded black diamond, proven live via
pixelPtrDiffered) regardless of the engine's occlusion computation — no
neighbour math, no partials. Simulation untouched (CnCNet-safe, same class
as bounty marks). Marks MUST be kept in sync with engine state by the
caller (unmark on rewatch); a stale mark is a permanent black square.
DynamicFow.SetOverrideFrame(15) -- runtime opt-in, no env needed -> true
DynamicFow.SetCell(x, y, true) -- mark one cell -> boolean
DynamicFow.SetCell(x, y, false) -- unmark
DynamicFow.ClearCells() -- drop all marks -> integer removed
DynamicFow.GetMarkCount() -- -> integerSetOverrideFrame(frame): frame < 0 disables back to passthrough;
> 48 rejected (false). Live consumer: dynamic_fow v0.7+ (marks only
deeper than sight+margin, instant unmark, 4-sweep combat grace).
Read the API
↓
Build a small Lua prototype
↓
Verify it in Yuri's Revenge
↓
Move unsafe native work into C++
↓
Expose a safe Lua binding
↓
Document the verified behavior
Build small. Test frequently. Verify before documenting.