diff --git a/CMakeLists.txt b/CMakeLists.txt index fc3ef8cd..fc3427b8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -918,6 +918,7 @@ set(TEST_FILES src/rwe/sim/patrol.test.cpp src/rwe/sim/capture.test.cpp src/rwe/sim/worksounds.test.cpp + src/rwe/sim/unitnotifications.test.cpp src/rwe/sim/burning.test.cpp src/rwe/sim/waterbuilding.test.cpp src/rwe/setup/TaInstall.test.cpp diff --git a/docs/TOTALA-EXE.md b/docs/TOTALA-EXE.md index 986340fe..8283ee06 100644 --- a/docs/TOTALA-EXE.md +++ b/docs/TOTALA-EXE.md @@ -9998,6 +9998,106 @@ The feature's `seqnamereclamate` swirl was already implemented (it plays on > the *when* -- in particular that a builder sent to a wreck it must walk to > announces nothing until it arrives. +### `underattack`, `repair` and `cant`: the three voices that were never played + +> **Ported, 2026-09-11.** `UnitDamagedEvent` carries the cause (its +> `paralyzer` flag) and the scene plays slot 2 off it under the gates below; +> `UnitRepairedEvent` is raised where a repair job lands, on both sides of +> it, and plays slot 10; `UnitCannotComplyEvent` carries the caption a +> refusing site passes and plays slot 7. Four of the forty-five `cant` +> sites are wired, the ones whose refusals RWE already makes: the nanoframe +> capture, the Commander reclaim, the pad that is taken, and the corpse that +> has gone. The rest are in the table for when their refusals exist. +> `src/rwe/sim/unitnotifications.test.cpp`. + +The rest of the slot table at `0x5086F0`, read out in full this time: + +| Id | Key | Caption | | Id | Key | Caption | +|---|---|---|---|---|---|---| +| 1 | `select` | | | 12 | `load` | | +| 2 | `underattack` | Under Attack | | 13 | `unload` | | +| 3 | `activate` | | | 14 | `cloak` | Cloaked | +| 4 | `deactivate` | | | 15 | `uncloak` | Visible | +| 5 | `ok` | | | 16 | `capture` | | +| 6 | `arrived` | Arrived | | 17-22 | `count5`..`count0` | five..zero | +| 7 | `cant` | Cannot Comply | | 23 | `canceldestruct` | Self destruct terminated | +| 8 | `unitcomplete` | Nanolathe Complete | | | | | +| 9 | `build` | | | | | | +| 10 | `repair` | | | | | | +| 11 | `working` | | | | | | + +**Three entry points, one player.** `0x47F780(unit, id, caption)` is the one +the eighty-two direct call sites use. It refuses a unit that is not the local +player's, or not alive (`unit+0x110` bit 28 clear or bit 14 set); takes the +caption passed in, or the table's if none; prints it (`0x4C5740`) and hands +`(unit, id, text)` to the player `0x47FAD0`. Two siblings differ only in one +extra gate, `0x48BCB0(unit)`, which walks the frame's draw list (the "HOT +UNITS" list at `world+0x1435F`, §18): `0x47F7E0` plays only for a unit *in* +the list and is never called; `0x47F850` plays only for a unit *not* in it, +and has one caller. + +**The player keeps a queue.** `0x47FAD0` holds up to eight pending voices of +17 bytes each. A slot id already waiting is not queued again (`0x47FB0A`), a +full queue drops its head, and the entries are kept in priority order from +the table's third dword (`underattack` 0x14, `cant` 1, `select` 0). So a unit +under sustained fire says "Under Attack" once per playback, not once per hit. +RWE has one reserved voice channel and drops a voice while it is busy, which +comes to nearly the same thing. + +**`underattack`, slot 2, one site.** `0x4071D8`, the tail of the return-fire +routine `0x406F80(attacker, victim, damage)` (§"Firing modes"). Every early +exit in that routine jumps to the tail rather than returning, so the voice is +independent of the fire mode and of whether the unit shot back. Its own gates: + +- `0x438BE0(victim)` returns the victim's current mission flags word + (`mission+0x42`); bit 7 set skips the voice. No mission handler sets that + bit directly in this binary; it is treated as never set. +- `[victim+0xF4]` (the owner of the last unit to damage it) differs from the + victim's owner: play. Otherwise play only if `[victim+0xF5]`, the cause + byte, is 1, a weapon hit -- so a paralyser from your own side is silent. +- Through `0x47F850`: only for a unit that is not being drawn this frame. It + is the warning for what you cannot see. + +**`repair`, slot 10, five sites, all with `0x5012CC` "Unit repaired".** +`0x402491` in `SELFREPAIR` (`0x402430`, the aircraft on a pad, once its hit +points meet the maximum), `0x415298` in `VTOL_GetRepaired` (`0x415250`, the +same from the air side), `0x415219` in `VTOL_RepairUnit` (`0x414E70`, the +repairer), and `0x4056FE`/`0x4057A1` in the ground repair body shared below +`0x4056A5` (the repairer again). Both ends of a repair say it. + +**`cant`, slot 7, 45 sites.** Not one passes a null caption; every site +overrides "Cannot Comply" with a message of its own, which is why the table's +caption is never seen. The sites, by caption: + +| Sites | Caption | +|---|---| +| `4046fe` | Capture failed | +| `402736` | Construction stopped | +| `403a3c, 403fb0, 413dba` | Construction terminated | +| `41473c` | Construction terminated by hostile action | +| `40407a` | I can't get there | +| `403c45` | I can't reach the construction site | +| `41190b` | Landing aborted | +| `411d36` | Landing aborted: all pads are occupied | +| `411e01` | Landing aborted: no pads available | +| `411c3a` | Landing failed | +| `4047a6, 4047e6, 404b00, 41479f, 414d69` | Reclamation failed | +| `40244e, 415268` | Repair aborted. | +| `414f8e` | Repair mission failed | +| `40531e, 4053aa, 40575e, 414e8e, 414f18` | Repairs unsuccessful. | +| `404f7e` | Ressurection failed | +| `403d10, 414055` | Target area was blocked | +| `4042ff` | That unit cannot be captured | +| `404799, 414d91` | That unit cannot be reclaimed | +| `40432e` | That unit is a cloud of vapor and cannot be captured | +| `4068d7, 411207, 411526` | Transport mission failed | +| `402907, 403d78, 405121, 4140b8` | Unable to create any more units | +| `411771` | Unable to unload unit | +| `411275` | Unit is too heavy to transport | +| `4067ea` | Unit is too large to transport | +| `406916` | Unloading process is proceeding non-optimally | +| `403cdf, 414020` | Waiting for target area to clear | + --- ## 98. Resurrect: a real mission, a crude corpse mapping, and nothing that can use it diff --git a/src/rwe/game/GameScene_commands.cpp b/src/rwe/game/GameScene_commands.cpp index d1bd6e46..80f42f54 100644 --- a/src/rwe/game/GameScene_commands.cpp +++ b/src/rwe/game/GameScene_commands.cpp @@ -1331,9 +1331,57 @@ namespace rwe if (auto victim = tryGetUnit(e.unitId)) { lastAttackPosition = victim->get().position; + + // Sound slot 2, `underattack`, "Under Attack". + // Its one call site (0x4071D8) is the tail of + // the return-fire routine, reached on every hit + // whatever the fire mode. It plays for damage + // from another side of any cause, and for the + // unit's own side only on a weapon hit; and it + // goes through the entry point that refuses a + // unit in the frame's draw list (0x47F850), so + // it is the warning for what you cannot see. + // The original also drops a repeat while one is + // waiting in its voice queue; here the reserved + // voice channel does the same by being busy. + // TOTALA-EXE.md §97. + auto otherSide = !e.attackerOwner || *e.attackerOwner != e.victimOwner; + if ((otherSide || !e.paralyzer) && !victim->get().isDead()) + { + auto viewProjectionMatrix = computeViewProjectionMatrix(worldCameraState, worldViewport.width(), worldViewport.height()); + auto viewCull = makeViewCullTest(viewProjectionMatrix); + if (!viewCull.couldBeVisible(simVectorToFloat(victim->get().position), ViewCullModelRadius)) + { + const auto& unitDefinition = simulation.unitDefinitions.at(victim->get().unitType); + printConsole(unitDefinition.unitName + ": Under Attack"); + playUnitNotificationSound(localPlayerId, victim->get().unitType, UnitSoundType::UnderAttack); + } + } } } }, + [&](const UnitRepairedEvent& e) { + // Sound slot 10, `repair`, with its caption "Unit + // repaired" (0x5012CC), from the repairer and from the + // aircraft on a pad alike. TOTALA-EXE.md §97. + if (auto unit = tryGetUnit(e.unitId); unit && unit->get().isOwnedBy(localPlayerId)) + { + const auto& unitDefinition = simulation.unitDefinitions.at(unit->get().unitType); + printConsole(unitDefinition.unitName + ": Unit repaired"); + playUnitNotificationSound(localPlayerId, unit->get().unitType, UnitSoundType::Repair); + } + }, + [&](const UnitCannotComplyEvent& e) { + // Sound slot 7, `cant`, with the caption the refusing + // site passes in place of the table's "Cannot Comply". + // TOTALA-EXE.md §97. + if (auto unit = tryGetUnit(e.unitId); unit && unit->get().isOwnedBy(localPlayerId)) + { + const auto& unitDefinition = simulation.unitDefinitions.at(unit->get().unitType); + printConsole(unitDefinition.unitName + ": " + e.message); + playUnitNotificationSound(localPlayerId, unit->get().unitType, UnitSoundType::Cant1); + } + }, [&](const UnitDiedEvent& e) { const auto& unitDefinition = simulation.unitDefinitions.at(e.unitType); diff --git a/src/rwe/sim/GameSimulation.cpp b/src/rwe/sim/GameSimulation.cpp index 2cc3e47a..dacf11d2 100644 --- a/src/rwe/sim/GameSimulation.cpp +++ b/src/rwe/sim/GameSimulation.cpp @@ -3499,7 +3499,7 @@ namespace rwe attackerOwner = attackerUnit->get().owner; } } - events.push_back(UnitDamagedEvent{unitId, getUnitState(unitId).owner, attackerOwner}); + events.push_back(UnitDamagedEvent{unitId, getUnitState(unitId).owner, attackerOwner, paralyzer}); } if (attacker) diff --git a/src/rwe/sim/GameSimulation.h b/src/rwe/sim/GameSimulation.h index 97e4b8c6..046da606 100644 --- a/src/rwe/sim/GameSimulation.h +++ b/src/rwe/sim/GameSimulation.h @@ -289,6 +289,46 @@ namespace rwe UnitId unitId; PlayerId victimOwner; std::optional attackerOwner; + + /** + * The original's damage cause byte (unit+0xF5): 1 is a weapon hit + * and 2 a paralyser. The under-attack voice, sound slot 2, plays + * for damage from another player whatever its cause, and for damage + * from the unit's own side only when it is a weapon hit + * (0x4071BA-0x4071D1). See TOTALA-EXE.md §97. + */ + bool paralyzer{false}; + }; + + /** + * A repair job has finished and its unit says "Unit repaired": sound + * slot 10, `repair`. The original plays it from both ends of the job -- + * the repairer when RepairUnit or VTOL_RepairUnit completes, and the + * aircraft itself when SELFREPAIR (0x402491) or VTOL_GetRepaired + * (0x415298) sees its hit points reach the maximum on a pad. See + * TOTALA-EXE.md §97. + * + * Emitted for the scene, and never hashed. + */ + struct UnitRepairedEvent + { + UnitId unitId; + }; + + /** + * An order the unit cannot carry out, with the caption the original + * prints for it: sound slot 7, `cant`, whose table caption is "Cannot + * Comply" but which every one of its forty-five call sites overrides + * with a message of its own ("That unit is a cloud of vapor and cannot + * be captured", "Landing aborted: no pads available", ...). See + * TOTALA-EXE.md §97 for the full list. + * + * Emitted for the scene, and never hashed. + */ + struct UnitCannotComplyEvent + { + UnitId unitId; + std::string message; }; struct UnitStartedBuildingEvent @@ -422,7 +462,9 @@ namespace rwe UnitStartedReclaimingEvent, ProjectileDiedEvent, ProjectileDetonatedEvent, - UnitCapturedEvent>; + UnitCapturedEvent, + UnitRepairedEvent, + UnitCannotComplyEvent>; struct UnitInfo diff --git a/src/rwe/sim/UnitBehaviorService.cpp b/src/rwe/sim/UnitBehaviorService.cpp index 967f2183..c856d39b 100644 --- a/src/rwe/sim/UnitBehaviorService.cpp +++ b/src/rwe/sim/UnitBehaviorService.cpp @@ -1821,6 +1821,7 @@ namespace rwe // Somebody else got there first. if (airBaseIsClaimedByAnother(*sim, order.target, unitInfo.id)) { + sim->events.push_back(UnitCannotComplyEvent{unitInfo.id, "Landing aborted: no pads available"}); return true; } @@ -1837,7 +1838,14 @@ namespace rwe // mending is not its work: the pad does that, in the builder // path, which is why an aircraft on a switched-off pad sits there // indefinitely instead of taking off again. - return unitInfo.state->hitPoints >= unitInfo.definition->maxHitPoints; + if (unitInfo.state->hitPoints >= unitInfo.definition->maxHitPoints) + { + // The aircraft's own "Unit repaired" (0x402491, and + // VTOL_GetRepaired's at 0x415298). + sim->events.push_back(UnitRepairedEvent{unitInfo.id}); + return true; + } + return false; } if (navigateTo(unitInfo, pad.position)) @@ -3576,6 +3584,8 @@ namespace rwe const auto& targetDefinition = sim->unitDefinitions.at(target->get().unitType); if (targetDefinition.canCapture) { + // 0x50164C, from ReclaimUnit (0x4047A6). + sim->events.push_back(UnitCannotComplyEvent{unitInfo.id, "That unit cannot be reclaimed"}); return true; } } @@ -3595,7 +3605,8 @@ namespace rwe auto featureRef = sim->tryGetFeature(resurrectOrder.target); if (!featureRef) { - // "Ressurection failed" -- the corpse has gone. + // The corpse has gone. 0x501684, spelt as the original spells it. + sim->events.push_back(UnitCannotComplyEvent{unitInfo.id, "Ressurection failed"}); return true; } const auto& feature = featureRef->get(); @@ -3722,7 +3733,9 @@ namespace rwe if (target.hitPoints >= targetDefinition.maxHitPoints) { - // Nothing to repair. + // Nothing to repair. The "Unit repaired" voice is raised where + // the mending finishes, in deployRepairArm, so an order for a + // unit that was already whole says nothing. return true; } @@ -4054,6 +4067,7 @@ namespace rwe // thinks an unfinished building is. if (targetRef->get().isBeingBuilt(sim->unitDefinitions.at(targetRef->get().unitType))) { + sim->events.push_back(UnitCannotComplyEvent{unitInfo.id, "That unit is a cloud of vapor and cannot be captured"}); return true; } @@ -4972,6 +4986,10 @@ namespace rwe if (targetUnit.hitPoints >= targetUnitDefinition.maxHitPoints) { + // "Unit repaired", sound slot 10, from the repairer's + // side: RepairUnit and VTOL_RepairUnit (0x415219) say + // it as the job lands. + sim->events.push_back(UnitRepairedEvent{unitInfo.id}); changeState(*unitInfo.state, UnitBehaviorStateIdle()); return true; } diff --git a/src/rwe/sim/unitnotifications.test.cpp b/src/rwe/sim/unitnotifications.test.cpp new file mode 100644 index 00000000..b229537f --- /dev/null +++ b/src/rwe/sim/unitnotifications.test.cpp @@ -0,0 +1,213 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * The three unit voices that were never played -- `underattack`, `repair` + * and `cant` -- and the simulation events the scene plays them off. What + * triggers each was read out of the callers of 0x47F780, the routine that + * plays a sound slot: see TOTALA-EXE.md §97. + */ +namespace rwe +{ + namespace + { + MapTerrain makeFlatTerrain(int width = 32, int height = 32) + { + Grid heights(width, height, static_cast(0)); + return MapTerrain(std::move(heights), 0_ss); + } + + UnitDefinition makeBuilderDef(unsigned int workerTimePerTick) + { + UnitDefinition d{}; + d.builder = true; + d.canReclamate = true; + d.workerTimePerTick = workerTimePerTick; + d.maxHitPoints = 100; + d.buildTime = 0u; + d.buildDistance = 100_ss; + d.movementCollisionInfo = UnitDefinition::AdHocMovementClass{2u, 2u, 255u, 255u, 0u, 0u}; + return d; + } + + /** ARMSOLAR.FBI, as the capture and reclaim tests model it. */ + UnitDefinition makeSolarDef() + { + UnitDefinition d{}; + d.maxHitPoints = 326; + d.buildCostEnergy = Energy(760); + d.buildCostMetal = Metal(145); + d.buildTime = 2495u; + d.movementCollisionInfo = UnitDefinition::AdHocMovementClass{2u, 2u, 255u, 255u, 0u, 0u}; + return d; + } + + template + int countEvents(const GameSimulation& sim) + { + int n = 0; + for (const auto& e : sim.events) + { + if (std::holds_alternative(e)) + { + ++n; + } + } + return n; + } + + template + std::vector eventsOf(const GameSimulation& sim) + { + std::vector out; + for (const auto& e : sim.events) + { + if (auto p = std::get_if(&e)) + { + out.push_back(*p); + } + } + return out; + } + } + + TEST_CASE("a damaged unit's event says whether the hit was a paralyser", "[unitnotifications]") + { + // The under-attack voice turns on the original's cause byte: a + // weapon hit from your own side still warns, a paralyser from your + // own side does not. The scene decides; the event carries the fact. + auto script = makeEmptyCobScript(); + GameSimulation sim(makeFlatTerrain(), 0u, 0, 0); + auto player = addPlayer(sim); + sim.unitDefinitions["solar"] = makeSolarDef(); + auto solarId = addUnitOfType(sim, "solar", player, SimVector(100_ss, 0_ss, 100_ss), script); + + sim.applyDamage(solarId, 10u, std::nullopt, false); + sim.applyDamage(solarId, 10u, std::nullopt, true); + + auto damaged = eventsOf(sim); + REQUIRE(damaged.size() == 2); + REQUIRE(damaged[0].unitId == solarId); + REQUIRE_FALSE(damaged[0].paralyzer); + REQUIRE(damaged[1].paralyzer); + } + + TEST_CASE("a repairer says so once when the job is done", "[unitnotifications]") + { + auto script = makeEmptyCobScript(); + GameSimulation sim(makeFlatTerrain(), 0u, 0, 0); + auto player = addPlayer(sim); + sim.unitDefinitions["solar"] = makeSolarDef(); + sim.unitDefinitions["builder"] = makeBuilderDef(30u); + + auto solarId = addUnitOfType(sim, "solar", player, SimVector(200_ss, 0_ss, 200_ss), script); + sim.getUnitState(solarId).hitPoints = 300u; + auto builderId = addUnitOfType(sim, "builder", player, SimVector(240_ss, 0_ss, 200_ss), script); + sim.getUnitState(builderId).inBuildStance = true; + sim.getUnitState(builderId).orders.push_back(RepairOrder(solarId)); + + // Nothing until the target is whole. + sim.tick(); + sim.events.clear(); + REQUIRE(sim.getUnitState(solarId).hitPoints < 326u); + + int ticks = 0; + while (ticks < 100 && !sim.getUnitState(builderId).orders.empty()) + { + sim.tick(); + ++ticks; + } + REQUIRE(sim.getUnitState(solarId).hitPoints == 326u); + REQUIRE(sim.getUnitState(builderId).orders.empty()); + + auto repaired = eventsOf(sim); + REQUIRE(repaired.size() == 1); + // The voice is the repairer's, not the mended unit's. + REQUIRE(repaired[0].unitId == builderId); + } + + TEST_CASE("cannot comply: a nanoframe is a cloud of vapor", "[unitnotifications]") + { + // 0x40432E, with 0x5015E0. + auto script = makeEmptyCobScript(); + GameSimulation sim(makeFlatTerrain(), 0u, 0, 0); + auto player = addPlayer(sim, "captor"); + auto enemy = addPlayer(sim, "enemy"); + sim.unitDefinitions["solar"] = makeSolarDef(); + auto captorDef = makeBuilderDef(10u); + captorDef.canCapture = true; + sim.unitDefinitions["captor"] = captorDef; + + auto solarId = addUnitOfType(sim, "solar", enemy, SimVector(200_ss, 0_ss, 200_ss), script); + sim.getUnitState(solarId).buildTimeCompleted = 1u; + auto captorId = addUnitOfType(sim, "captor", player, SimVector(240_ss, 0_ss, 200_ss), script); + sim.getUnitState(captorId).orders.push_back(CaptureOrder(solarId)); + + sim.tick(); + + REQUIRE(sim.getUnitState(captorId).orders.empty()); + auto refused = eventsOf(sim); + REQUIRE(refused.size() == 1); + REQUIRE(refused[0].unitId == captorId); + REQUIRE(refused[0].message == "That unit is a cloud of vapor and cannot be captured"); + } + + TEST_CASE("cannot comply: a commander cannot be reclaimed", "[unitnotifications]") + { + // 0x4047A6, with 0x50164C. + auto script = makeEmptyCobScript(); + GameSimulation sim(makeFlatTerrain(), 0u, 0, 0); + auto player = addPlayer(sim); + auto commanderDef = makeBuilderDef(30u); + commanderDef.canCapture = true; + sim.unitDefinitions["commander"] = commanderDef; + sim.unitDefinitions["builder"] = makeBuilderDef(30u); + auto commanderId = addUnitOfType(sim, "commander", player, SimVector(200_ss, 0_ss, 200_ss), script); + auto builderId = addUnitOfType(sim, "builder", player, SimVector(240_ss, 0_ss, 200_ss), script); + sim.getUnitState(builderId).inBuildStance = true; + sim.getUnitState(builderId).orders.push_back(ReclaimOrder(commanderId)); + + sim.tick(); + + REQUIRE(sim.getUnitState(builderId).orders.empty()); + auto refused = eventsOf(sim); + REQUIRE(refused.size() == 1); + REQUIRE(refused[0].unitId == builderId); + REQUIRE(refused[0].message == "That unit cannot be reclaimed"); + } + + TEST_CASE("a refusal that ends an order says so exactly once", "[unitnotifications]") + { + // The order goes with the message, so ticking on is silent. + auto script = makeEmptyCobScript(); + GameSimulation sim(makeFlatTerrain(), 0u, 0, 0); + auto player = addPlayer(sim, "captor"); + auto enemy = addPlayer(sim, "enemy"); + sim.unitDefinitions["solar"] = makeSolarDef(); + auto captorDef = makeBuilderDef(10u); + captorDef.canCapture = true; + sim.unitDefinitions["captor"] = captorDef; + auto solarId = addUnitOfType(sim, "solar", enemy, SimVector(200_ss, 0_ss, 200_ss), script); + sim.getUnitState(solarId).buildTimeCompleted = 1u; + auto captorId = addUnitOfType(sim, "captor", player, SimVector(240_ss, 0_ss, 200_ss), script); + sim.getUnitState(captorId).orders.push_back(CaptureOrder(solarId)); + + for (int i = 0; i < 5; ++i) + { + sim.tick(); + } + REQUIRE(countEvents(sim) == 1); + } +}