From 1784beafa44dc99b5ef595a75222209b6a5221c2 Mon Sep 17 00:00:00 2001 From: Hector Bailey Date: Fri, 11 Sep 2026 21:14:52 +0100 Subject: [PATCH] A blast hurts any feature that is not indestructible The original's blast-on-feature routine (0x4244B0) 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 a hit like a wreck does. RWE's gate had asked for blocking or reclaimable as well, which came from the wreckage pass and kept 523 shipped features immune. Three more things the routine settles. The damage added is the weapon's default damage, unscaled by distance; the feature breaks to its featuredead the moment what it has taken reaches the definition's damage; and the feature parser defaults a missing damage key to zero, so a feature that names none goes on the first hit. RWE's reader had defaulted it to 1. One consequence of the walk is kept the original's way: a feature stood up by the blast in progress lands on the cell just walked and is not met again by it, so a heap that names no damage does not go in the same shell that made it. The fire branch, where a firestarter weapon sets a flammable feature alight instead of damaging it, is written up and left for its own pass. The console command that switches all of this off turns out to be TreeDeath. Issue: #45 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PJCbwC9MGZnM6erMDKptun --- docs/TOTALA-EXE.md | 60 +++++++++++++++++++++++++++++++ src/rwe/io/featuretdf/io.cpp | 7 +++- src/rwe/io/featuretdf/io.test.cpp | 3 +- src/rwe/sim/GameSimulation.cpp | 54 +++++++++++++++++----------- src/rwe/sim/GameSimulation.h | 3 +- src/rwe/sim/wreckage.test.cpp | 49 +++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 24 deletions(-) diff --git a/docs/TOTALA-EXE.md b/docs/TOTALA-EXE.md index 7afefc9ed..4ad066e24 100644 --- a/docs/TOTALA-EXE.md +++ b/docs/TOTALA-EXE.md @@ -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 diff --git a/src/rwe/io/featuretdf/io.cpp b/src/rwe/io/featuretdf/io.cpp index 0c66b93d3..b4761bc90 100644 --- a/src/rwe/io/featuretdf/io.cpp +++ b/src/rwe/io/featuretdf/io.cpp @@ -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); diff --git a/src/rwe/io/featuretdf/io.test.cpp b/src/rwe/io/featuretdf/io.test.cpp index b6ca75526..836ce07e7 100644 --- a/src/rwe/io/featuretdf/io.test.cpp +++ b/src/rwe/io/featuretdf/io.test.cpp @@ -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); } } } diff --git a/src/rwe/sim/GameSimulation.cpp b/src/rwe/sim/GameSimulation.cpp index 38a9bbd3a..139f57a64 100644 --- a/src/rwe/sim/GameSimulation.cpp +++ b/src/rwe/sim/GameSimulation.cpp @@ -460,12 +460,12 @@ namespace rwe return true; } - void GameSimulation::replaceFeature(FeatureId id, const std::optional& replacement) + std::optional GameSimulation::replaceFeature(FeatureId id, const std::optional& replacement) { auto featureRef = tryGetFeature(id); if (!featureRef) { - return; + return std::nullopt; } auto position = featureRef->get().position; @@ -473,10 +473,11 @@ namespace rwe 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) @@ -3674,11 +3675,12 @@ namespace rwe std::unordered_set seenUnits; std::unordered_set 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); @@ -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(simScalarToUInt(SimScalar(static_cast(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(scaled) ? feature.hitPoints - static_cast(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; + } } } } diff --git a/src/rwe/sim/GameSimulation.h b/src/rwe/sim/GameSimulation.h index 97e4b8c69..e1751e893 100644 --- a/src/rwe/sim/GameSimulation.h +++ b/src/rwe/sim/GameSimulation.h @@ -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& replacement); + /** Removes the feature and stands its replacement, if any, in its place; returns the replacement's id. */ + std::optional replaceFeature(FeatureId id, const std::optional& replacement); /** * Applies workAmount of reclaim work to a feature on behalf of a player, diff --git a/src/rwe/sim/wreckage.test.cpp b/src/rwe/sim/wreckage.test.cpp index 40e2aec8c..d2fa3c082 100644 --- a/src/rwe/sim/wreckage.test.cpp +++ b/src/rwe/sim/wreckage.test.cpp @@ -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,