// Host tests for the B2 field adapter: the offsets the tech-effect callback writes, the // float32 round-trip through them, and the region table the hook declares. // // This is the coverage that does not need the game: the reference save completes a tech // only now and then, so the compare on the VM validates whichever techs happen to finish, // while every catalogued effect is exercised here against a synthetic ServerPlayer. #include "shim/hooks/tech_effect_fields.h" #include #include #include #include "check.h" // shared with tests/game_sim #include "game/effects/tech_effects.h" #include "game/effects/tech_id.h" #include "generated/sots_addresses.h" namespace A = sots::addr; namespace tfx = shim::hooks::techfx; using namespace sots::effects; using sots::sim::Species; namespace { // A synthetic player object: big enough for every declared region, plus a separate // 3-word block for the node-bore parameters. struct FakePlayer { std::vector bytes; int bore[3] = {0, 0, 0}; FakePlayer() : bytes(tfx::PlayerSpan() + 0x40, 0) {} void* base() { return bytes.data(); } template void put(std::uint32_t off, T v) { std::memcpy(bytes.data() + off, &v, sizeof v); } template T get(std::uint32_t off) const { T v{}; std::memcpy(&v, bytes.data() + off, sizeof v); return v; } // The node-bore block pointer is a 32-bit word in the game and a 64-bit one on this // host, so writing a real pointer into the buffer would spill over the neighbouring // field. The tests point the view at the block directly instead, which is what // Views::OverPlayer would do on the target. tfx::Views views(bool with_bore) { tfx::Views v = tfx::Views::OverPlayer(bytes.data()); v.base[tfx::R_NODEBORE] = with_bore ? bore : nullptr; return v; } // The "no tech yet" starting values a fresh player carries. void seed_defaults(Species sp) { std::fill(bytes.begin(), bytes.end(), 0); put(A::ServerPlayer_off_Species, static_cast(sp)); put(A::ServerPlayer_off_pddm, 1.f); for (int i = 0; i < 3; ++i) put(A::ServerPlayer_off_ConMod + 4u * i, 1.f); for (int i = 0; i < 3; ++i) put(A::ServerPlayer_off_SavMod + 4u * i, 1.f); put(A::ServerPlayer_off_OutMod, 1.f); put(A::ServerPlayer_off_PopMod, 1.f); put(A::ServerPlayer_off_TerraMod, 1.f); put(A::ServerPlayer_off_ResMod, 1.f); put(A::ServerPlayer_off_IncMod, 1.f); put(A::ServerPlayer_off_AIBn, 1); } }; ApplyContext plain_ctx(sots::sim::TuningTable& t) { ApplyContext c; c.tuning = &t; return c; } // Run one completion through the adapter exactly as the hook does. TechApplyOutcome run_completion(FakePlayer& p, TechId id, const ApplyContext& ctx, const std::vector& already = {}, bool with_bore = true) { tfx::Views v = p.views(with_bore); PlayerEconomyState s = tfx::ReadPlayerState( v, static_cast(p.get(A::ServerPlayer_off_Species))); for (TechId t : already) s.researched.set(static_cast(TechIdIndex(t))); if (IsValidTechId(id)) s.researched.set(static_cast(TechIdIndex(id))); TechApplyOutcome o = ApplyTechCompletion(s, id, ctx); tfx::WritePlayerState(v, s); return o; } // ---- tests ------------------------------------------------------------------------------ void test_region_table() { // Every region carries a name and a describer, and no two overlap. for (int i = 0; i < tfx::kRegionCount; ++i) { const tfx::RegionDef& d = tfx::kRegions[i]; CHECK(d.name != nullptr && d.name[0] != '\0'); CHECK(d.describe != nullptr); CHECK(d.size > 0); } for (int i = 0; i < tfx::kRegionCount; ++i) { if (i == tfx::R_NODEBORE) continue; // a separate allocation, not part of the object for (int j = i + 1; j < tfx::kRegionCount; ++j) { if (j == tfx::R_NODEBORE) continue; const std::uint32_t a0 = tfx::kRegions[i].off, a1 = a0 + tfx::kRegions[i].size; const std::uint32_t b0 = tfx::kRegions[j].off, b1 = b0 + tfx::kRegions[j].size; CHECK(a1 <= b0 || b1 <= a0); } } // The declared span must reach past the last region. for (int i = 0; i < tfx::kRegionCount; ++i) { if (i == tfx::R_NODEBORE) continue; CHECK(tfx::kRegions[i].off + tfx::kRegions[i].size <= tfx::PlayerSpan()); } // The node-bore region is the only one that may legitimately be absent: on the target // the block pointer is the 32-bit word at its offset, and a zero word means "no block". FakePlayer p; p.seed_defaults(Species::Human); CHECK_EQ(p.get(A::ServerPlayer_off_NodeBore), 0u); CHECK(p.views(true).base[tfx::R_NODEBORE] == p.bore); CHECK(p.views(false).base[tfx::R_NODEBORE] == nullptr); } void test_read_round_trip() { FakePlayer p; p.seed_defaults(Species::Morrigi); p.put(A::ServerPlayer_off_SuitTol, 1.25f); p.put(A::ServerPlayer_off_MaxOH, 0.5f); p.put(A::ServerPlayer_off_ResMod, 1.75f); p.put(A::ServerPlayer_off_IncMod, 2.5f); p.put(A::ServerPlayer_off_RebAI, 1); p.put(A::ServerPlayer_off_AIBn, 0); p.put(A::ServerPlayer_off_hadvs, 1); p.put(A::ServerPlayer_off_AMine, 1); p.put(A::ServerPlayer_off_pddm, 0.75f); p.put(A::ServerPlayer_off_ConMod + 4, 0.8f); p.put(A::ServerPlayer_off_SavMod + 8, 0.6f); p.put(A::ServerPlayer_off_OutMod, 3.25f); p.put(A::ServerPlayer_off_PopMod, 1.125f); p.put(A::ServerPlayer_off_TerraMod, 4.5f); p.put(A::ServerPlayer_off_MinRate, 2.f); p.put(A::ServerPlayer_off_PrGtTrf, 37); p.put(A::ServerPlayer_off_CstR, 10.f); p.put(A::ServerPlayer_off_CstR + 4, 2.f); p.put(A::ServerPlayer_off_CstR + 8, 1.f); p.put(A::ServerPlayer_off_HasVac, 0x03); p.put(A::ServerPlayer_off_HasVac + 4, 0x05); p.put(A::ServerPlayer_off_NPTrk, 0x21); p.put(A::ServerPlayer_off_TranslationKnown, 0x09); p.put(A::ServerPlayer_off_CaptureDesigns, 1); p.bore[0] = 65; p.bore[1] = 35; p.bore[2] = 4; const PlayerEconomyState s = tfx::ReadPlayerState(p.views(true), Species::Morrigi); CHECK(s.species == Species::Morrigi); CHECK(s.suitTol == 1.25f); CHECK(s.maxOverharvest == 0.5f); CHECK(s.resMod == 1.75f); CHECK(s.incMod == 2.5f); CHECK(s.rebelAI); CHECK(!s.aiBenefit); CHECK(s.Flag(PlayerFlag::AdvancedSensors)); CHECK(s.Flag(PlayerFlag::AsteroidMining)); CHECK(s.Flag(PlayerFlag::CaptureDesigns)); CHECK(s.defenceDamageMod == 0.75f); CHECK(s.conMod[1] == 0.8f); CHECK(s.savMod[2] == 0.6f); CHECK(s.outMod == 3.25f); CHECK(s.popMod == 1.125f); CHECK(s.terraMod == 4.5f); CHECK(s.miningRate == 2.f); CHECK_EQ(s.perGateTraffic, 37); CHECK(s.castRange == 10.f && s.castEfficiency == 2.f && s.castThreshold == 1.f); CHECK_EQ(s.hasVaccine, 0x03u); CHECK_EQ(s.hasImmunity, 0x05u); CHECK_EQ(s.nodeTrackMask, 0x21u); CHECK_EQ(s.translationKnownMask, 0x09u); CHECK(s.hasNodeBoreParams); CHECK_EQ(s.nodeBoreParams[0], 65); CHECK_EQ(s.nodeBoreParams[2], 4); // Write it straight back out: nothing the callback writes may change. FakePlayer q; q.seed_defaults(Species::Morrigi); tfx::WritePlayerState(q.views(true), s); CHECK(q.get(A::ServerPlayer_off_SuitTol) == 1.25f); CHECK(q.get(A::ServerPlayer_off_OutMod) == 3.25f); CHECK_EQ(q.get(A::ServerPlayer_off_PrGtTrf), 37); CHECK_EQ(q.get(A::ServerPlayer_off_TranslationKnown), 0x09u); CHECK_EQ(q.bore[1], 35); // The species-flag word count is part of the assignment the original makes. CHECK_EQ(q.get(A::ServerPlayer_off_SpeciesTechFlags + 0x1c), static_cast(sots::sim::kSpeciesCount)); } // Every catalogued effect, applied to a synthetic player, byte-checked at the offsets the // original writes. The values themselves are pinned in tests/game_effects; what is under // test here is that they land in the right words in the right representation. void test_every_effect_lands_in_the_right_word() { sots::sim::TuningTable t; t.PERGATETRAFFIC_DRV_TpGate = 7; t.PERGATETRAFFIC_DRV_GatAmp = 19; ApplyContext ctx = plain_ctx(t); int covered = 0; for (int i = 0; i < kTechIdCount; ++i) { const TechId id = TechIdFromIndex(i); if (EffectsOf(id).empty()) continue; ++covered; FakePlayer p; p.seed_defaults(Species::Zuul); // the one species with an extra branch run_completion(p, id, ctx); // Independently: the same completion on a plain state seeded the same way. PlayerEconomyState want; want.species = Species::Zuul; want.hasNodeBoreParams = true; want.researched.set(static_cast(i)); ApplyTechCompletion(want, id, ctx); CHECK(p.get(A::ServerPlayer_off_SuitTol) == want.suitTol); CHECK(p.get(A::ServerPlayer_off_MaxOH) == want.maxOverharvest); CHECK(p.get(A::ServerPlayer_off_ResMod) == want.resMod); CHECK(p.get(A::ServerPlayer_off_IncMod) == want.incMod); CHECK(p.get(A::ServerPlayer_off_pddm) == want.defenceDamageMod); for (int k = 0; k < 3; ++k) CHECK(p.get(A::ServerPlayer_off_ConMod + 4u * k) == want.conMod[k]); for (int k = 0; k < 3; ++k) CHECK(p.get(A::ServerPlayer_off_SavMod + 4u * k) == want.savMod[k]); CHECK(p.get(A::ServerPlayer_off_OutMod) == want.outMod); CHECK(p.get(A::ServerPlayer_off_PopMod) == want.popMod); CHECK(p.get(A::ServerPlayer_off_TerraMod) == want.terraMod); CHECK(p.get(A::ServerPlayer_off_MinRate) == want.miningRate); CHECK_EQ(p.get(A::ServerPlayer_off_PrGtTrf), want.perGateTraffic); CHECK(p.get(A::ServerPlayer_off_CstR) == want.castRange); CHECK(p.get(A::ServerPlayer_off_CstR + 4) == want.castEfficiency); CHECK(p.get(A::ServerPlayer_off_CstR + 8) == want.castThreshold); CHECK_EQ(p.get(A::ServerPlayer_off_HasVac), want.hasVaccine); CHECK_EQ(p.get(A::ServerPlayer_off_HasVac + 4), want.hasImmunity); CHECK_EQ(p.get(A::ServerPlayer_off_hadvs) != 0, want.Flag(PlayerFlag::AdvancedSensors)); CHECK_EQ(p.get(A::ServerPlayer_off_harcc) != 0, want.Flag(PlayerFlag::Arcology)); CHECK_EQ(p.get(A::ServerPlayer_off_AMine) != 0, want.Flag(PlayerFlag::AsteroidMining)); CHECK_EQ(p.get(A::ServerPlayer_off_CnTrd) != 0, want.Flag(PlayerFlag::TradeAllowed)); CHECK_EQ(p.get(A::ServerPlayer_off_CnRad) != 0, want.Flag(PlayerFlag::CommerceRaiding)); CHECK_EQ(p.get(A::ServerPlayer_off_CnVItl) != 0, want.Flag(PlayerFlag::ViewIntel)); CHECK_EQ(p.get(A::ServerPlayer_off_hgs) != 0, want.Flag(PlayerFlag::GravSynth)); CHECK_EQ(p.bore[0], want.nodeBoreParams[0]); } CHECK_EQ(covered, 44); } // Spot checks with the constants written out by hand, so a silent change to the table or // to the rounding shape fails here and not only in the aggregate above. void test_exact_float32_bit_patterns() { sots::sim::TuningTable t; ApplyContext ctx = plain_ctx(t); FakePlayer p; p.seed_defaults(Species::Human); run_completion(p, TechId::IND_Waldo, ctx); CHECK(p.get(A::ServerPlayer_off_OutMod) == static_cast(1.0 + static_cast(0.15f))); CHECK(p.get(A::ServerPlayer_off_ConMod) == static_cast(1.0 - static_cast(0.10f))); // Multiplicative, on top of the additive one. run_completion(p, TechId::IND_HrdStrct, ctx, {TechId::IND_Waldo}); const float after_waldo = static_cast(1.0 + static_cast(0.15f)); CHECK(p.get(A::ServerPlayer_off_OutMod) == static_cast(static_cast(after_waldo) * static_cast(0.9f))); CHECK(p.get(A::ServerPlayer_off_pddm) == 0.25f); // The suitability techs are exactly representable, so they must be exact. FakePlayer q; q.seed_defaults(Species::Liir); q.put(A::ServerPlayer_off_SuitTol, 0.5f); run_completion(q, TechId::BIO_AtmoAd, ctx); CHECK(q.get(A::ServerPlayer_off_SuitTol) == 1.25f); run_completion(q, TechId::BIO_GrvAdpt, ctx, {TechId::BIO_AtmoAd}); CHECK(q.get(A::ServerPlayer_off_SuitTol) == 2.75f); // Far-casting writes three literals, one of them via FLD1. FakePlayer r; r.seed_defaults(Species::Morrigi); run_completion(r, TechId::DRV_FarCast, ctx); CHECK(r.get(A::ServerPlayer_off_CstR) == 10.f); CHECK(r.get(A::ServerPlayer_off_CstR + 4) == 2.f); CHECK(r.get(A::ServerPlayer_off_CstR + 8) == 1.f); } void test_gate_traffic_and_bore_absent() { sots::sim::TuningTable t; t.PERGATETRAFFIC_DRV_TpGate = 7; t.PERGATETRAFFIC_DRV_GatAmp = 19; ApplyContext ctx = plain_ctx(t); FakePlayer p; p.seed_defaults(Species::Hiver); p.put(A::ServerPlayer_off_PrGtTrf, 12); run_completion(p, TechId::DRV_TpGate, ctx); CHECK_EQ(p.get(A::ServerPlayer_off_PrGtTrf), 12); // integer max keeps 12 run_completion(p, TechId::DRV_GatAmp, ctx, {TechId::DRV_TpGate}); CHECK_EQ(p.get(A::ServerPlayer_off_PrGtTrf), 19); // With no block attached the bore parameters have nowhere to go, and writing must not // fault or scribble on the object. FakePlayer q; q.seed_defaults(Species::Zuul); const std::vector before = q.bytes; run_completion(q, TechId::DRV_RAD, ctx, {}, /*with_bore=*/false); CHECK(q.bore[0] == 0 && q.bore[1] == 0 && q.bore[2] == 0); CHECK(std::memcmp(before.data() + A::ServerPlayer_off_NodeBore, q.bytes.data() + A::ServerPlayer_off_NodeBore, sizeof(void*)) == 0); } void test_species_flags_and_translation() { sots::sim::TuningTable t; ApplyContext ctx = plain_ctx(t); FakePlayer p; p.seed_defaults(Species::Human); run_completion(p, XenoTechId(XenoLevel::Translation1, Species::Tarkas), ctx); CHECK_EQ(p.get(A::ServerPlayer_off_SpeciesTechFlags + 4u * static_cast(Species::Tarkas)), 1u); CHECK_EQ(p.get(A::ServerPlayer_off_TranslationKnown), 1u << static_cast(Species::Tarkas)); run_completion(p, XenoTechId(XenoLevel::Temperance, Species::Tarkas), ctx, {XenoTechId(XenoLevel::Translation1, Species::Tarkas)}); CHECK_EQ(p.get(A::ServerPlayer_off_SpeciesTechFlags + 4u * static_cast(Species::Tarkas)), 1u | (1u << static_cast(XenoLevel::Temperance))); } void test_research_target_and_masks() { FakePlayer p; p.seed_defaults(Species::Human); int def = 0; p.put(A::ServerPlayer_off_ResearchTarget, &def); p.put(A::ServerPlayer_off_ResearchRollPending, 1); tfx::Views v = p.views(false); int other = 0; CHECK(!tfx::ClearResearchTargetIfMatched(v, &other)); // a different definition CHECK(p.get(A::ServerPlayer_off_ResearchTarget) == &def); CHECK(tfx::ClearResearchTargetIfMatched(v, &def)); // the roll was pending CHECK(p.get(A::ServerPlayer_off_ResearchTarget) == nullptr); CHECK_EQ(p.get(A::ServerPlayer_off_ResearchRollPending), 0); CHECK(!tfx::ClearResearchTargetIfMatched(v, nullptr) || p.get(A::ServerPlayer_off_ResearchTarget) == nullptr); tfx::WriteDesignOptionMasks(v, 0xdeadbeefu, 0x1fffffffu); CHECK_EQ(p.get(A::ServerPlayer_off_TechMaskA), 0xdeadbeefu); CHECK_EQ(p.get(A::ServerPlayer_off_TechMaskA + 4), 0x1fffffffu); } // The describers must name fields, not dump bytes: a diff has to be able to point at // `side.modifiers.after.v.out_mod`. void test_describers_name_fields() { FakePlayer p; p.seed_defaults(Species::Human); p.put(A::ServerPlayer_off_OutMod, 1.5f); p.put(A::ServerPlayer_off_PrGtTrf, 42); const shim::trace::Tv tv = tfx::kRegions[tfx::R_MODIFIERS].describe(p.bytes.data() + A::ServerPlayer_off_pddm, tfx::kRegions[tfx::R_MODIFIERS].size, 256); shim::trace::Buf b; shim::trace::emit_tv(b, tv); const std::string json = b.str(); CHECK(json.find("\"out_mod\"") != std::string::npos); CHECK(json.find("\"per_gate_traffic\"") != std::string::npos); CHECK(json.find("\"con_mod\"") != std::string::npos); CHECK(json.find("42") != std::string::npos); } } // namespace int main() { test_region_table(); test_read_round_trip(); test_every_effect_lands_in_the_right_word(); test_exact_float32_bit_patterns(); test_gate_traffic_and_bore_absent(); test_species_flags_and_translation(); test_research_target_and_masks(); test_describers_name_fields(); return simtest::finish("shim_techfx"); }