Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/TOTALA-EXE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4635,6 +4635,66 @@ with no linear speed as moving (`0x43DA8E` also tests `WORD [mov+0x24]`); RWE
measures the distance the unit actually covered this tick and calls anything
under a tenth of a unit stopped.

### What a blast does to a feature, `0x4244B0`

> **Ported, 2026-09-11** (B4 #45). The blast gate in `doProjectileImpact` is
> `!indestructible` and nothing else; the damage added is the weapon's default
> damage, unscaled; the feature breaks to its `featuredead` the moment what it
> has taken reaches its `damage`; the feature reader defaults a missing
> `damage` to zero; and a feature stood up by the blast in progress is not met
> again by it. `wreckage.test.cpp` pins each. Not ported: the fire branch
> below, which RWE still handles with its own chance roll in
> `tryIgniteFeaturesInRadius`.

The area walkers (`0x455496` from the blast at `0x4554xx`, and `0x49A626`)
call `0x4244B0(cellRecord, x, z, wdef)` for each map square in the blast. The
routine loads the globals pointer and falls into `0x4244B6`, which is why no
call to the latter address exists. In order:

1. `[globals+0x37F2F] & 8` must be set. That word is the console-toggle word
(`NoShake` is its bit 4, 搂"Screen shake"); bit 3 is **`TreeDeath`**
(handler `0x416E30`, record at `0x502118`), and the session set-up at
`0x430E90` turns it on. `TreeDeath 0` makes every feature immune.
2. The square must hold a feature: `[cell+0x8]` below `0xFFFB`.
3. Its definition (`[globals+0x1426F] + index * 0x100`) must not be
`indestructible` (`[featdef+0xFE]` bit 9). **That is the whole of the
gate.** `blocking`, `reclaimable` and the rest are never read here, so a
rock, a bush, a wreck and a scar decal are all fair game.
4. In a multiplayer session (`0x435100` = 3) a peer that is not the local
authority (`[player+0x97]` bit 0 clear) does not apply the damage; it packs
`(x, z, wdef+0x10A)` into a type `0xF`/`0x6` message and sends it
(`0x450030`, `0x44FDB0`, `0x451BC0`). The rest is the local path.
5. **Fire first.** If the definition is `flamable` (bit 4), the weapon's
`firestarter` byte (`wdef+0x10B`) is non-zero, and the feature is not
already burning (`[cell+0xC]` bit 0), the feature is set alight
(`0x4233A0`, 搂"Burning") and takes **no damage**. No chance roll happens
here; whatever `firestarter`'s percentage means, it is not tested on the
way in.
6. Otherwise the weapon's default damage, the word at `wdef+0xD4` (the
`default` key, parsed at `0x42EF9A`-`0x42EFA9`), is added to what the
feature has taken: `[cell+0xA]` for a feature that is not burning, or the
burn record's `[+0x26]` (the 48-byte table at `globals+0x1420B`, found by
the index in `[cell+0xA]` and checked against `[+0x28]`/`[+0x2A]`) for one
that is. There is no distance falloff and no `edgeeffectiveness`; those are
for units.
7. If the total reaches `[featdef+0xEA]`, the definition's `damage`, the
feature is removed and its `featuredead` stood up (`0x423550(x, z, 0)`).

**The default `damage` is zero.** The feature parser (`0x422A20`) reads every
integer key through the integer-with-default helper `0x4C46C0`, passing the
same register as the default for the whole block, and that register is zeroed
once at `0x4226F4`. `damage` comes off the stack at `0x422A87` straight into
`featdef+0xEA`. So the 145 shipped features that omit the key (the `LightScar`
and `CarScar` decals among them) go on the first hit, and the 378 that say
`damage=20000` (the `Sl-RockScar` family) take the hit like anything else and
in practice never fall. RWE's reader used to default the key to 1, which for
the blast was the same thing and for reclaim work was a floor it did not need.

**On the flag word's other bits**, since they had to be read to find bit 3:
`Drop` is bit 0, `ShareMetal` bit 1, `0x416E00`'s command bit 2, `TreeDeath`
bit 3, `NoShake` bit 4, `Clock` bit 6, `0x417030`'s bit 7, `0x417060`'s bit 8,
`0x417090`'s bit 9, `ShootAll` bit 10.

## 25. The detection rings on the minimap

Sections 25 to 29 read the same part of the game -- what the interface draws
Expand Down
7 changes: 6 additions & 1 deletion src/rwe/io/featuretdf/io.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ namespace rwe
f.blocking = tdf.extractBool("blocking").value_or(false);

f.indestructible = tdf.extractBool("indestructible").value_or(false);
f.damage = tdf.extractUint("damage").value_or(1);
// The original's feature parser (0x422A20) reads every integer key
// through one helper with a default it seeds to zero for the whole
// block (xor esi,esi at 0x4226F4), so a feature that omits `damage`
// has none: the first blast to reach it takes it. See TOTALA-EXE.md
// 搂24, "What a blast does to a feature".
f.damage = tdf.extractUint("damage").value_or(0);
f.seqNameDie = tdf.findValue("seqnamedie").value_or(emptyString);
f.featureDead = tdf.findValue("featuredead").value_or(emptyString);

Expand Down
3 changes: 2 additions & 1 deletion src/rwe/io/featuretdf/io.test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ namespace rwe
REQUIRE(f.permanent == false);
REQUIRE(f.blocking == true);
REQUIRE(f.indestructible == false);
REQUIRE(f.damage == 1);
// The original defaults a missing `damage` to nothing at all.
REQUIRE(f.damage == 0);
}
}
}
54 changes: 33 additions & 21 deletions src/rwe/sim/GameSimulation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -460,23 +460,24 @@ namespace rwe
return true;
}

void GameSimulation::replaceFeature(FeatureId id, const std::optional<FeatureDefinitionId>& replacement)
std::optional<FeatureId> GameSimulation::replaceFeature(FeatureId id, const std::optional<FeatureDefinitionId>& replacement)
{
auto featureRef = tryGetFeature(id);
if (!featureRef)
{
return;
return std::nullopt;
}

auto position = featureRef->get().position;
auto rotation = featureRef->get().rotation;

deleteFeature(id);

if (replacement)
if (!replacement)
{
addFeature(MapFeature{*replacement, position, rotation});
return std::nullopt;
}
return addFeature(MapFeature{*replacement, position, rotation});
}

void GameSimulation::igniteFeature(FeatureId id)
Expand Down Expand Up @@ -3674,11 +3675,12 @@ namespace rwe
std::unordered_set<UnitId> seenUnits;
std::unordered_set<FeatureId> seenFeatures;

// Blasts hurt wreckage too: force-attacking a wreck field to clear a
// Blasts hurt features too: force-attacking a wreck field to clear a
// lane is a standing part of play, and controlling one is an economy
// in itself. A feature's own `damage` key is its hit points; a wreck
// in itself. A feature's own `damage` key is its hit points; a feature
// blown to nothing breaks down to its featureDead form the way a
// burnt one does, and a beam weapon never touches them at all.
// burnt one does. `damagesFeatures` is a mod's switch to exempt a
// weapon; the shipped data sets it on nothing.
auto damagesFeatures = weaponIt == weaponDefinitions.end() || weaponIt->second.damagesFeatures;

auto region = GridRegion::fromCoordinates(minCell, maxCell);
Expand Down Expand Up @@ -3707,25 +3709,35 @@ namespace rwe
{
auto& feature = featureRef->get();
const auto& featureDefinition = getFeatureDefinition(feature.featureName);
// Wreckage and rocks take the hit; an `indestructible`
// feature does not, whatever else it is. The shipped
// data has two hundred of those that are also
// blocking -- the Barrier walls, the dragon's teeth --
// and every one declares hit points, so without the
// flag they could be shelled flat.
if (!featureDefinition.indestructible && (featureDefinition.reclaimable || featureDefinition.blocking))
// The original's blast-on-feature routine (0x4244B0,
// TOTALA-EXE.md 搂24) asks one thing of the feature:
// that it is not `indestructible`. Blocking, reclaimable
// and the rest never enter into it, so a scar decal or
// a bush takes the hit like a wreck does. It adds the
// weapon's default damage, unscaled by distance, to what
// the feature has already taken, and breaks the feature
// to its featuredead form the moment that reaches the
// definition's `damage` -- which for a feature that
// omits the key is zero, so the first hit takes it.
if (!featureDefinition.indestructible)
{
auto distance = (feature.position - position).length();
auto scale = blastDamageScale(distance, radius, projectile.edgeEffectiveness);
auto scaled = static_cast<int>(simScalarToUInt(SimScalar(static_cast<float>(projectile.getDamage(std::string()))) * scale));
if (scaled > 0 && feature.hitPoints > 0)
auto damage = projectile.getDamage(std::string());
if (damage >= feature.hitPoints)
{
feature.hitPoints = feature.hitPoints > static_cast<unsigned int>(scaled) ? feature.hitPoints - static_cast<unsigned int>(scaled) : 0;
if (feature.hitPoints == 0)
// What stands in its place lands on the cell
// just walked and is not met again by this
// blast, as in the original's per-cell walk;
// without that a heap that names no damage
// would go in the same shell that made it.
if (auto replacement = replaceFeature(*cell.featureId, featureDefinition.featureDead))
{
replaceFeature(*cell.featureId, featureDefinition.featureDead);
seenFeatures.insert(*replacement);
}
}
else
{
feature.hitPoints -= damage;
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/rwe/sim/GameSimulation.h
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,8 @@ namespace rwe
* feature turns into a lesser one: burning out into its featureBurnt form.
* No-op if the id is stale; the replacement is dropped if it does not fit.
*/
void replaceFeature(FeatureId id, const std::optional<FeatureDefinitionId>& replacement);
/** Removes the feature and stands its replacement, if any, in its place; returns the replacement's id. */
std::optional<FeatureId> replaceFeature(FeatureId id, const std::optional<FeatureDefinitionId>& replacement);

/**
* Applies workAmount of reclaim work to a feature on behalf of a player,
Expand Down
49 changes: 49 additions & 0 deletions src/rwe/sim/wreckage.test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,55 @@ namespace rwe
REQUIRE(foundRubble);
}

SECTION("scenery that is neither blocking nor reclaimable takes the hit too")
{
// 0x4244B0 asks only that the feature is not indestructible. A
// scar decal, modelled on the shipped Sl-RockScar (damage=20000,
// nothing else set), loses the weapon's damage like a wreck.
auto scar = makeWreckDef("scar", 0u, 20000u);
scar.blocking = false;
scar.reclaimable = false;
scar.autoreclaimable = false;
auto scarDef = sim.featureDefinitions.insert(scar);
auto scarId = sim.addFeature(scarDef, 8, 8).value();
auto position = sim.getFeature(scarId).position;

sim.doProjectileImpact(makeShell(position, 500u, 64_ss), ImpactType::Normal);

REQUIRE(sim.getFeature(scarId).hitPoints == 19500u);
}

SECTION("a feature that names no damage goes on the first hit")
{
// The parser defaults a missing `damage` to zero, and the blast
// routine breaks the feature as soon as what it has taken reaches
// that. The LightScar decals are the shipped case.
auto decal = makeWreckDef("decal", 0u, 0u);
decal.blocking = false;
decal.reclaimable = false;
decal.featureDead = rubbleDef;
auto decalDef = sim.featureDefinitions.insert(decal);
auto decalId = sim.addFeature(decalDef, 8, 8).value();
auto position = sim.getFeature(decalId).position;

sim.doProjectileImpact(makeShell(position, 1u, 64_ss), ImpactType::Normal);

REQUIRE_FALSE(sim.tryGetFeature(decalId).has_value());
}

SECTION("the whole default damage lands, wherever in the blast the feature stands")
{
// The routine adds wdef+0xD4, the weapon's default damage, with
// no look at the distance; edgeeffectiveness is for units.
auto rockId = sim.addFeature(rockDef, 8, 8).value();
auto position = sim.getFeature(rockId).position;

auto shell = makeShell(position + SimVector(48_ss, 0_ss, 0_ss), 500u, 64_ss);
sim.doProjectileImpact(shell, ImpactType::Normal);

REQUIRE(sim.getFeature(rockId).hitPoints == 1500u);
}

SECTION("an indestructible feature shrugs off a blast")
{
// Modelled on the Barrier walls and the dragon's teeth: blocking,
Expand Down