sots-engine/tests/shim_techfx/unit_tests.cpp

396 lines
18 KiB
C++

// 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 <cstring>
#include <string>
#include <vector>
#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<unsigned char> bytes;
int bore[3] = {0, 0, 0};
FakePlayer() : bytes(tfx::PlayerSpan() + 0x40, 0) {}
void* base() { return bytes.data(); }
template <class T>
void put(std::uint32_t off, T v) { std::memcpy(bytes.data() + off, &v, sizeof v); }
template <class T>
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.
// The generator is reached through two 32-bit pointer hops; the same 64-bit-host
// caveat as the node-bore block applies, so tests aim the view at it directly.
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<std::int32_t>(A::ServerPlayer_off_Species, static_cast<int>(sp));
put<float>(A::ServerPlayer_off_pddm, 1.f);
for (int i = 0; i < 3; ++i) put<float>(A::ServerPlayer_off_ConMod + 4u * i, 1.f);
for (int i = 0; i < 3; ++i) put<float>(A::ServerPlayer_off_SavMod + 4u * i, 1.f);
put<float>(A::ServerPlayer_off_OutMod, 1.f);
put<float>(A::ServerPlayer_off_PopMod, 1.f);
put<float>(A::ServerPlayer_off_TerraMod, 1.f);
put<float>(A::ServerPlayer_off_ResMod, 1.f);
put<float>(A::ServerPlayer_off_IncMod, 1.f);
put<std::uint8_t>(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<TechId>& already = {}, bool with_bore = true) {
tfx::Views v = p.views(with_bore);
PlayerEconomyState s = tfx::ReadPlayerState(
v, static_cast<Species>(p.get<std::int32_t>(A::ServerPlayer_off_Species)));
for (TechId t : already) s.researched.set(static_cast<std::size_t>(TechIdIndex(t)));
if (IsValidTechId(id)) s.researched.set(static_cast<std::size_t>(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);
}
// Two regions live outside the player object, so their `off` is meaningless.
auto in_object = [](int i) { return i != tfx::R_NODEBORE && i != tfx::R_RNG; };
for (int i = 0; i < tfx::kRegionCount; ++i) {
if (!in_object(i)) continue;
for (int j = i + 1; j < tfx::kRegionCount; ++j) {
if (!in_object(j)) 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 (!in_object(i)) continue;
CHECK(tfx::kRegions[i].off + tfx::kRegions[i].size <= tfx::PlayerSpan());
}
// The generator region is the whole Mars::RNG object, and it is absent when the player
// has no server link (a synthetic object, or a client-side player).
CHECK_EQ(tfx::kRegions[tfx::R_RNG].size, static_cast<std::size_t>(A::RNG_size));
// 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<std::uint32_t>(A::ServerPlayer_off_NodeBore), 0u);
CHECK(p.views(true).base[tfx::R_NODEBORE] == p.bore);
CHECK(p.views(false).base[tfx::R_NODEBORE] == nullptr);
CHECK(tfx::Views::OverPlayer(p.base()).base[tfx::R_RNG] == nullptr);
CHECK(tfx::Views::OverPlayer(p.base()).liveRng == nullptr);
}
void test_read_round_trip() {
FakePlayer p;
p.seed_defaults(Species::Morrigi);
p.put<float>(A::ServerPlayer_off_SuitTol, 1.25f);
p.put<float>(A::ServerPlayer_off_MaxOH, 0.5f);
p.put<float>(A::ServerPlayer_off_ResMod, 1.75f);
p.put<float>(A::ServerPlayer_off_IncMod, 2.5f);
p.put<std::uint8_t>(A::ServerPlayer_off_RebAI, 1);
p.put<std::uint8_t>(A::ServerPlayer_off_AIBn, 0);
p.put<std::uint8_t>(A::ServerPlayer_off_hadvs, 1);
p.put<std::uint8_t>(A::ServerPlayer_off_AMine, 1);
p.put<float>(A::ServerPlayer_off_pddm, 0.75f);
p.put<float>(A::ServerPlayer_off_ConMod + 4, 0.8f);
p.put<float>(A::ServerPlayer_off_SavMod + 8, 0.6f);
p.put<float>(A::ServerPlayer_off_OutMod, 3.25f);
p.put<float>(A::ServerPlayer_off_PopMod, 1.125f);
p.put<float>(A::ServerPlayer_off_TerraMod, 4.5f);
p.put<float>(A::ServerPlayer_off_MinRate, 2.f);
p.put<std::int32_t>(A::ServerPlayer_off_PrGtTrf, 37);
p.put<float>(A::ServerPlayer_off_CstR, 10.f);
p.put<float>(A::ServerPlayer_off_CstR + 4, 2.f);
p.put<float>(A::ServerPlayer_off_CstR + 8, 1.f);
p.put<std::uint32_t>(A::ServerPlayer_off_HasVac, 0x03);
p.put<std::uint32_t>(A::ServerPlayer_off_HasVac + 4, 0x05);
p.put<std::uint32_t>(A::ServerPlayer_off_NPTrk, 0x21);
p.put<std::uint32_t>(A::ServerPlayer_off_TranslationKnown, 0x09);
p.put<std::uint8_t>(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<float>(A::ServerPlayer_off_SuitTol) == 1.25f);
CHECK(q.get<float>(A::ServerPlayer_off_OutMod) == 3.25f);
CHECK_EQ(q.get<std::int32_t>(A::ServerPlayer_off_PrGtTrf), 37);
CHECK_EQ(q.get<std::uint32_t>(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<std::uint32_t>(A::ServerPlayer_off_SpeciesTechFlags + 0x1c),
static_cast<std::uint32_t>(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<std::size_t>(i));
ApplyTechCompletion(want, id, ctx);
CHECK(p.get<float>(A::ServerPlayer_off_SuitTol) == want.suitTol);
CHECK(p.get<float>(A::ServerPlayer_off_MaxOH) == want.maxOverharvest);
CHECK(p.get<float>(A::ServerPlayer_off_ResMod) == want.resMod);
CHECK(p.get<float>(A::ServerPlayer_off_IncMod) == want.incMod);
CHECK(p.get<float>(A::ServerPlayer_off_pddm) == want.defenceDamageMod);
for (int k = 0; k < 3; ++k)
CHECK(p.get<float>(A::ServerPlayer_off_ConMod + 4u * k) == want.conMod[k]);
for (int k = 0; k < 3; ++k)
CHECK(p.get<float>(A::ServerPlayer_off_SavMod + 4u * k) == want.savMod[k]);
CHECK(p.get<float>(A::ServerPlayer_off_OutMod) == want.outMod);
CHECK(p.get<float>(A::ServerPlayer_off_PopMod) == want.popMod);
CHECK(p.get<float>(A::ServerPlayer_off_TerraMod) == want.terraMod);
CHECK(p.get<float>(A::ServerPlayer_off_MinRate) == want.miningRate);
CHECK_EQ(p.get<std::int32_t>(A::ServerPlayer_off_PrGtTrf), want.perGateTraffic);
CHECK(p.get<float>(A::ServerPlayer_off_CstR) == want.castRange);
CHECK(p.get<float>(A::ServerPlayer_off_CstR + 4) == want.castEfficiency);
CHECK(p.get<float>(A::ServerPlayer_off_CstR + 8) == want.castThreshold);
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_HasVac), want.hasVaccine);
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_HasVac + 4), want.hasImmunity);
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_hadvs) != 0,
want.Flag(PlayerFlag::AdvancedSensors));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_harcc) != 0, want.Flag(PlayerFlag::Arcology));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_AMine) != 0,
want.Flag(PlayerFlag::AsteroidMining));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_CnTrd) != 0,
want.Flag(PlayerFlag::TradeAllowed));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_CnRad) != 0,
want.Flag(PlayerFlag::CommerceRaiding));
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_CnVItl) != 0,
want.Flag(PlayerFlag::ViewIntel));
CHECK_EQ(p.get<std::uint8_t>(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<float>(A::ServerPlayer_off_OutMod) ==
static_cast<float>(1.0 + static_cast<double>(0.15f)));
CHECK(p.get<float>(A::ServerPlayer_off_ConMod) ==
static_cast<float>(1.0 - static_cast<double>(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<float>(1.0 + static_cast<double>(0.15f));
CHECK(p.get<float>(A::ServerPlayer_off_OutMod) ==
static_cast<float>(static_cast<double>(after_waldo) * static_cast<double>(0.9f)));
CHECK(p.get<float>(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<float>(A::ServerPlayer_off_SuitTol, 0.5f);
run_completion(q, TechId::BIO_AtmoAd, ctx);
CHECK(q.get<float>(A::ServerPlayer_off_SuitTol) == 1.25f);
run_completion(q, TechId::BIO_GrvAdpt, ctx, {TechId::BIO_AtmoAd});
CHECK(q.get<float>(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<float>(A::ServerPlayer_off_CstR) == 10.f);
CHECK(r.get<float>(A::ServerPlayer_off_CstR + 4) == 2.f);
CHECK(r.get<float>(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<std::int32_t>(A::ServerPlayer_off_PrGtTrf, 12);
run_completion(p, TechId::DRV_TpGate, ctx);
CHECK_EQ(p.get<std::int32_t>(A::ServerPlayer_off_PrGtTrf), 12); // integer max keeps 12
run_completion(p, TechId::DRV_GatAmp, ctx, {TechId::DRV_TpGate});
CHECK_EQ(p.get<std::int32_t>(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<unsigned char> 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<std::uint32_t>(A::ServerPlayer_off_SpeciesTechFlags +
4u * static_cast<int>(Species::Tarkas)),
1u);
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_TranslationKnown),
1u << static_cast<int>(Species::Tarkas));
run_completion(p, XenoTechId(XenoLevel::Temperance, Species::Tarkas), ctx,
{XenoTechId(XenoLevel::Translation1, Species::Tarkas)});
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_SpeciesTechFlags +
4u * static_cast<int>(Species::Tarkas)),
1u | (1u << static_cast<int>(XenoLevel::Temperance)));
}
void test_research_target_and_masks() {
FakePlayer p;
p.seed_defaults(Species::Human);
int def = 0;
p.put<void*>(A::ServerPlayer_off_ResearchTarget, &def);
p.put<std::uint8_t>(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<void*>(A::ServerPlayer_off_ResearchTarget) == &def);
CHECK(tfx::ClearResearchTargetIfMatched(v, &def)); // the roll was pending
CHECK(p.get<void*>(A::ServerPlayer_off_ResearchTarget) == nullptr);
CHECK_EQ(p.get<std::uint8_t>(A::ServerPlayer_off_ResearchRollPending), 0);
CHECK(!tfx::ClearResearchTargetIfMatched(v, nullptr) ||
p.get<void*>(A::ServerPlayer_off_ResearchTarget) == nullptr);
tfx::WriteDesignOptionMasks(v, 0xdeadbeefu, 0x1fffffffu);
CHECK_EQ(p.get<std::uint32_t>(A::ServerPlayer_off_TechMaskA), 0xdeadbeefu);
CHECK_EQ(p.get<std::uint32_t>(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<float>(A::ServerPlayer_off_OutMod, 1.5f);
p.put<std::int32_t>(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");
}