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,