feat(core): simulation déterministe, enregistrement et rejeu

Virgule fixe Q16.16, générateur PCG32 à graine explicite, horloge à pas fixe exacte, simulation d'un micro-mode de démonstration (marqué SIMULÉ), empreinte FNV-1a de l'état, enregistrement binaire versionné des entrées, rejeu avec d'autres réglages de ressenti et détection du premier tick divergent. Empreinte de référence multiplateforme figée. 22 tests (Debug, Release, ASan).
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-06 19:50:56 +01:00
1 parent a03accb388
commit ab219ad611
18 files changed
+1191 -3

No files matched your search

+10 -2
View File
@@ -3,13 +3,16 @@
configure_file(src/version_config.hpp.in "${CMAKE_CURRENT_BINARY_DIR}/generated/sequa/core/version_config.hpp" @ONLY)
add_library(sequa_core
src/version.cpp)
src/version.cpp
src/sim/simulation.cpp
src/sim/replay.cpp)
add_library(sequa::core ALIAS sequa_core)
target_include_directories(sequa_core
PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include"
PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src"
"${CMAKE_CURRENT_BINARY_DIR}/generated")
target_compile_features(sequa_core PUBLIC cxx_std_20)
sequa_target_defaults(sequa_core)
@@ -17,7 +20,12 @@ sequa_target_defaults(sequa_core)
if(SEQUA_BUILD_TESTS)
add_executable(sequa_core_tests
tests/main.cpp
tests/test_version.cpp)
tests/test_version.cpp
tests/sim/test_fixed.cpp
tests/sim/test_rng.cpp
tests/sim/test_clock.cpp
tests/sim/test_simulation.cpp
tests/sim/test_replay.cpp)
target_link_libraries(sequa_core_tests PRIVATE sequa::core doctest::doctest)
sequa_target_defaults(sequa_core_tests)
doctest_discover_tests(sequa_core_tests)
+13 -1
View File
@@ -20,7 +20,19 @@ Core est la **source de vérité** du projet. Il possède :
## État
Phase 0. Seule la bibliothèque `sequa::core` minimale (version) et son test existent. Prochaine étape : simulation déterministe et test de rejeu.
Phase 0. Simulation déterministe, enregistrement et rejeu en place (`include/sequa/core/sim/`) :
| Élément | Fichier | Rôle |
|---|---|---|
| `Fixed` | `fixed.hpp` | Virgule fixe Q16.16 : calcul entier, identique bit à bit partout |
| `Pcg32` | `rng.hpp` | Générateur aléatoire SEQUA à graine explicite (PCG32) |
| `FixedStepClock` | `clock.hpp` | Temps réel vers pas fixes, calcul entier exact, retard plafonné |
| `Simulation` | `simulation.hpp` | Pas fixe, entrées quantifiées, empreinte FNV-1a de l'état complet |
| `Recording`, `SessionRecorder`, `replay` | `replay.hpp` | Enregistrement des entrées horodatées, format binaire versionné, rejeu avec d'autres réglages, premier tick divergent |
**SIMULÉ / PROVISOIRE** : le monde est un micro-mode 2D de démonstration (un joueur, un sol, des pièces, réglages de ressenti) qui sert à éprouver le déterminisme avant la vraie scène L0. Il sera remplacé.
Le test « empreinte de référence multiplateforme » fige l'empreinte d'une session d'une minute : toute plateforme doit la retrouver exactement (§10.1, §16).
## Construire et tester
+46
View File
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstdint>
namespace sequa::core::sim {
// Convertit le temps réel écoulé en nombre de pas de simulation à fréquence fixe,
// indépendamment de l'affichage (docs, §5.3). Calcul entier exact : aucun pas n'est
// perdu ni gagné, même pour des fréquences qui ne divisent pas la seconde (60 Hz).
class FixedStepClock {
public:
// Préconditions : tick_rate_hz > 0, max_steps_per_advance > 0.
explicit FixedStepClock(std::uint32_t tick_rate_hz, std::uint32_t max_steps_per_advance = 8) noexcept
: tick_rate_hz_(tick_rate_hz), max_steps_(max_steps_per_advance)
{
}
// Retourne le nombre de pas à exécuter pour elapsed_ns nanosecondes écoulées.
// Au-delà de max_steps_per_advance, le retard est abandonné (évite la spirale où la
// simulation ne rattrape jamais le temps réel) et compté dans dropped_steps().
[[nodiscard]] std::uint32_t advance(std::uint64_t elapsed_ns) noexcept
{
accumulator_ += elapsed_ns * tick_rate_hz_;
const std::uint64_t due = accumulator_ / ns_per_second;
accumulator_ %= ns_per_second;
if (due > max_steps_) {
dropped_steps_ += due - max_steps_;
return max_steps_;
}
return static_cast<std::uint32_t>(due);
}
[[nodiscard]] std::uint32_t tick_rate_hz() const noexcept { return tick_rate_hz_; }
[[nodiscard]] std::uint64_t dropped_steps() const noexcept { return dropped_steps_; }
private:
static constexpr std::uint64_t ns_per_second = 1'000'000'000ull;
std::uint32_t tick_rate_hz_;
std::uint32_t max_steps_;
std::uint64_t accumulator_ = 0; // en ns * Hz
std::uint64_t dropped_steps_ = 0;
};
} // namespace sequa::core::sim
+92
View File
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <compare>
#include <cstdint>
namespace sequa::core::sim {
// Nombre en virgule fixe signé Q16.16.
//
// La simulation L0 calcule en entiers pour être identique bit à bit sur toutes les
// plateformes et tous les compilateurs (docs, §5.3 et §10.1). Les calculs intermédiaires
// passent par 64 bits ; le retour en 32 bits est modulaire (comportement défini en C++20),
// donc un dépassement reste déterministe.
class Fixed {
public:
static constexpr int fraction_bits = 16;
static constexpr std::int32_t one_raw = std::int32_t{1} << fraction_bits;
constexpr Fixed() noexcept = default;
[[nodiscard]] static constexpr Fixed from_raw(std::int32_t raw) noexcept
{
Fixed f;
f.raw_ = raw;
return f;
}
[[nodiscard]] static constexpr Fixed from_int(std::int32_t value) noexcept
{
return from_raw(narrow(static_cast<std::int64_t>(value) * one_raw));
}
// numerator / denominator, arrondi vers zéro. Précondition : denominator != 0.
[[nodiscard]] static constexpr Fixed from_ratio(std::int32_t numerator, std::int32_t denominator) noexcept
{
return from_raw(narrow((static_cast<std::int64_t>(numerator) * one_raw) / denominator));
}
[[nodiscard]] constexpr std::int32_t raw() const noexcept { return raw_; }
// Affichage et outils uniquement : jamais dans la simulation.
[[nodiscard]] constexpr double to_double() const noexcept { return static_cast<double>(raw_) / one_raw; }
friend constexpr Fixed operator+(Fixed a, Fixed b) noexcept
{
return from_raw(narrow(static_cast<std::int64_t>(a.raw_) + b.raw_));
}
friend constexpr Fixed operator-(Fixed a, Fixed b) noexcept
{
return from_raw(narrow(static_cast<std::int64_t>(a.raw_) - b.raw_));
}
friend constexpr Fixed operator-(Fixed a) noexcept { return from_raw(narrow(-static_cast<std::int64_t>(a.raw_))); }
// Produit arrondi vers moins l'infini (décalage arithmétique, défini en C++20).
friend constexpr Fixed operator*(Fixed a, Fixed b) noexcept
{
return from_raw(narrow((static_cast<std::int64_t>(a.raw_) * b.raw_) >> fraction_bits));
}
// Quotient arrondi vers zéro. Précondition : b != 0.
friend constexpr Fixed operator/(Fixed a, Fixed b) noexcept
{
return from_raw(narrow((static_cast<std::int64_t>(a.raw_) * one_raw) / b.raw_));
}
constexpr Fixed& operator+=(Fixed other) noexcept { return *this = *this + other; }
constexpr Fixed& operator-=(Fixed other) noexcept { return *this = *this - other; }
constexpr Fixed& operator*=(Fixed other) noexcept { return *this = *this * other; }
friend constexpr auto operator<=>(Fixed, Fixed) noexcept = default;
private:
[[nodiscard]] static constexpr std::int32_t narrow(std::int64_t value) noexcept
{
return static_cast<std::int32_t>(value);
}
std::int32_t raw_ = 0;
};
[[nodiscard]] constexpr Fixed abs(Fixed value) noexcept
{
return value < Fixed{} ? -value : value;
}
[[nodiscard]] constexpr Fixed clamp(Fixed value, Fixed low, Fixed high) noexcept
{
return value < low ? low : (high < value ? high : value);
}
} // namespace sequa::core::sim
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstdint>
namespace sequa::core::sim {
using Tick = std::uint64_t;
namespace button {
inline constexpr std::uint32_t jump = 1u << 0u;
} // namespace button
// Entrées du joueur pour un tick, déjà quantifiées : aucune valeur flottante, pour que
// l'enregistrement et le rejeu soient exacts (docs, §5.3).
struct InputFrame {
Tick tick = 0;
std::uint32_t buttons = 0;
std::int16_t move_x = 0; // [-32767, 32767] : gauche, droite
std::int16_t move_y = 0; // réservé
[[nodiscard]] constexpr bool is_neutral() const noexcept { return buttons == 0 && move_x == 0 && move_y == 0; }
friend constexpr bool operator==(const InputFrame&, const InputFrame&) noexcept = default;
};
[[nodiscard]] constexpr InputFrame neutral_input(Tick tick) noexcept
{
return InputFrame{tick, 0, 0, 0};
}
} // namespace sequa::core::sim
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include "sequa/core/sim/input.hpp"
#include "sequa/core/sim/simulation.hpp"
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>
#include <vector>
namespace sequa::core::sim {
// Session enregistrée (docs, §5.3) : la graine, la fréquence, la durée et les entrées
// horodatées suffisent à la rejouer à l'identique, avec les mêmes réglages ou d'autres.
// Les réglages de ressenti ne sont volontairement pas enregistrés : ce sont eux que
// l'on fait varier au rejeu.
struct Recording {
std::uint64_t seed = 0;
std::uint32_t tick_rate_hz = 60;
Tick tick_count = 0;
std::vector<InputFrame> inputs; // non neutres, ticks strictement croissants, < tick_count
// Vrai si les entrées sont ordonnées, uniques par tick, dans la durée et non neutres.
[[nodiscard]] bool is_valid() const noexcept;
friend bool operator==(const Recording&, const Recording&) = default;
};
// Format binaire versionné, petit-boutiste : "SQRC", version, en-tête, entrées.
[[nodiscard]] std::vector<std::byte> serialize(const Recording& recording);
// Retourne std::nullopt si les octets sont tronqués, d'une autre version ou incohérents.
[[nodiscard]] std::optional<Recording> deserialize_recording(std::span<const std::byte> bytes);
// Enregistre une session pendant qu'elle se joue.
class SessionRecorder {
public:
explicit SessionRecorder(const SimConfig& config);
// Exécute un tick avec l'entrée donnée (son champ tick est ignoré et remplacé par le
// tick courant) et l'enregistre si elle n'est pas neutre.
void step(InputFrame input);
[[nodiscard]] const Simulation& simulation() const noexcept { return simulation_; }
[[nodiscard]] const Recording& recording() const noexcept { return recording_; }
private:
Simulation simulation_;
Recording recording_;
};
struct ReplayResult {
std::vector<std::uint64_t> hashes; // empreinte après chaque tick
WorldState final_world;
std::uint64_t final_hash = 0;
};
// Rejoue une session avec des réglages de ressenti et d'arène donnés.
[[nodiscard]] ReplayResult replay(const Recording& recording, const FeelParams& feel,
const ArenaParams& arena = ArenaParams{});
// Premier tick (0-indexé) dont l'empreinte diffère, ou std::nullopt si les deux rejeux
// sont identiques. Des longueurs différentes divergent au premier tick manquant.
[[nodiscard]] std::optional<Tick> first_divergence(const ReplayResult& a, const ReplayResult& b) noexcept;
} // namespace sequa::core::sim
+61
View File
@@ -0,0 +1,61 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstdint>
namespace sequa::core::sim {
// Générateur pseudo-aléatoire propre à SEQUA, à graine explicite (docs, §10.1).
// Algorithme PCG32 (PCG-XSH-RR 64/32, M. E. O'Neill, licence Apache 2.0 pour la
// référence) réimplémenté ici : sorties identiques sur toutes les plateformes.
class Pcg32 {
public:
Pcg32(std::uint64_t seed, std::uint64_t stream) noexcept : inc_((stream << 1u) | 1u)
{
(void)next();
state_ += seed;
(void)next();
}
[[nodiscard]] std::uint32_t next() noexcept
{
const std::uint64_t old = state_;
state_ = old * multiplier + inc_;
const auto xorshifted = static_cast<std::uint32_t>(((old >> 18u) ^ old) >> 27u);
const auto rotation = static_cast<std::uint32_t>(old >> 59u);
return (xorshifted >> rotation) | (xorshifted << ((32u - rotation) & 31u));
}
// Entier uniforme dans [0, bound), sans biais (rejet). Précondition : bound > 0.
[[nodiscard]] std::uint32_t bounded(std::uint32_t bound) noexcept
{
const auto threshold = static_cast<std::uint32_t>((std::uint64_t{1} << 32u) % bound);
for (;;) {
const std::uint32_t value = next();
if (value >= threshold) {
return value % bound;
}
}
}
// Entier uniforme dans [low, high]. Précondition : low <= high.
[[nodiscard]] std::int32_t range(std::int32_t low, std::int32_t high) noexcept
{
const auto span = static_cast<std::uint32_t>(static_cast<std::int64_t>(high) - low + 1);
if (span == 0) { // intervalle complet des int32
return static_cast<std::int32_t>(next());
}
return static_cast<std::int32_t>(static_cast<std::int64_t>(low) + bounded(span));
}
[[nodiscard]] std::uint64_t state() const noexcept { return state_; }
[[nodiscard]] std::uint64_t increment() const noexcept { return inc_; }
private:
static constexpr std::uint64_t multiplier = 6364136223846793005ull;
std::uint64_t state_ = 0;
std::uint64_t inc_;
};
} // namespace sequa::core::sim
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include "sequa/core/sim/input.hpp"
#include "sequa/core/sim/rng.hpp"
#include "sequa/core/sim/world.hpp"
#include <cstddef>
#include <cstdint>
#include <vector>
namespace sequa::core::sim {
struct SimConfig {
std::uint64_t seed = 0;
std::uint32_t tick_rate_hz = 60;
FeelParams feel;
ArenaParams arena;
};
// Simulation déterministe à pas fixe (docs, §5.3) : même configuration et mêmes entrées
// donnent le même état, bit à bit, sur toutes les plateformes. Aucun modèle neuronal ou
// génératif n'intervient ici.
class Simulation {
public:
// Préconditions : tick_rate_hz > 0, arena.coin_count > 0.
explicit Simulation(const SimConfig& config);
// Avance d'un tick. input.tick doit être égal à tick() ; sinon l'entrée est
// remplacée par une entrée neutre (une entrée hors séquence ne doit jamais
// modifier l'état).
void step(const InputFrame& input);
[[nodiscard]] Tick tick() const noexcept { return tick_; }
[[nodiscard]] const WorldState& world() const noexcept { return world_; }
[[nodiscard]] const SimConfig& config() const noexcept { return config_; }
// Empreinte de l'état complet (tick, générateur aléatoire, monde) : FNV-1a 64 bits
// sur une sérialisation canonique petit-boutiste.
[[nodiscard]] std::uint64_t state_hash() const;
[[nodiscard]] std::vector<std::byte> serialize_state() const;
private:
Coin spawn_coin();
SimConfig config_;
Fixed dt_;
Pcg32 rng_;
WorldState world_;
Tick tick_ = 0;
};
} // namespace sequa::core::sim
+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include "sequa/core/sim/fixed.hpp"
#include <cstdint>
#include <vector>
namespace sequa::core::sim {
// SIMULÉ / PROVISOIRE : micro-mode de démonstration en 2D vue de côté (un joueur, un sol,
// des pièces). Il sert à éprouver le déterminisme et le rejeu avant la vraie scène L0
// (docs, §9 « Réel et simulé »).
// Réglages de ressenti (game feel, docs §4.2) : ce que l'auteur modifie avant de rejouer
// une session à l'identique. Unités : mètres et secondes.
struct FeelParams {
Fixed gravity = Fixed::from_int(30);
Fixed jump_velocity = Fixed::from_int(12);
Fixed move_acceleration = Fixed::from_int(40);
Fixed max_speed = Fixed::from_int(8);
Fixed ground_friction = Fixed::from_int(10); // par seconde
Fixed air_control = Fixed::from_ratio(1, 2); // fraction de l'accélération en l'air
std::uint32_t coyote_ticks = 6; // saut toléré après avoir quitté le sol
std::uint32_t jump_buffer_ticks = 6; // appui mémorisé avant l'atterrissage
friend constexpr bool operator==(const FeelParams&, const FeelParams&) noexcept = default;
};
struct Body {
Fixed x;
Fixed y;
Fixed vx;
Fixed vy;
bool on_ground = true;
std::uint32_t ticks_since_ground = 0;
std::uint32_t jump_buffer = 0;
std::uint32_t previous_buttons = 0;
};
struct Coin {
std::uint32_t id = 0;
Fixed x;
Fixed y;
};
struct ArenaParams {
Fixed half_width = Fixed::from_int(20);
Fixed coin_min_height = Fixed::from_ratio(1, 2);
Fixed coin_max_height = Fixed::from_int(3);
Fixed pickup_radius = Fixed::from_ratio(1, 2);
std::uint32_t coin_count = 8;
};
struct WorldState {
Body player;
std::vector<Coin> coins; // ordre stable par identifiant : aucun conteneur non ordonné
std::uint32_t score = 0;
std::uint32_t next_coin_id = 0;
};
} // namespace sequa::core::sim
+84
View File
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: MPL-2.0
// Encodage binaire canonique petit-boutiste, interne à sequa_core.
#pragma once
#include <cstddef>
#include <cstdint>
#include <optional>
#include <span>
#include <utility>
#include <vector>
namespace sequa::core::sim::detail {
class ByteWriter {
public:
void u8(std::uint8_t value) { bytes_.push_back(static_cast<std::byte>(value)); }
void u16(std::uint16_t value) { put(value, 2); }
void u32(std::uint32_t value) { put(value, 4); }
void u64(std::uint64_t value) { put(value, 8); }
void i16(std::int16_t value) { u16(static_cast<std::uint16_t>(value)); }
void i32(std::int32_t value) { u32(static_cast<std::uint32_t>(value)); }
[[nodiscard]] std::vector<std::byte> take() { return std::move(bytes_); }
private:
void put(std::uint64_t value, int size)
{
for (int i = 0; i < size; ++i) {
bytes_.push_back(static_cast<std::byte>((value >> (8 * i)) & 0xFFu));
}
}
std::vector<std::byte> bytes_;
};
class ByteReader {
public:
explicit ByteReader(std::span<const std::byte> bytes) noexcept : bytes_(bytes) {}
[[nodiscard]] std::optional<std::uint8_t> u8() { return get<std::uint8_t>(1); }
[[nodiscard]] std::optional<std::uint16_t> u16() { return get<std::uint16_t>(2); }
[[nodiscard]] std::optional<std::uint32_t> u32() { return get<std::uint32_t>(4); }
[[nodiscard]] std::optional<std::uint64_t> u64() { return get<std::uint64_t>(8); }
[[nodiscard]] std::optional<std::int16_t> i16()
{
const auto value = u16();
return value ? std::optional{static_cast<std::int16_t>(*value)} : std::nullopt;
}
[[nodiscard]] std::size_t remaining() const noexcept { return bytes_.size() - offset_; }
private:
template <typename T>
[[nodiscard]] std::optional<T> get(std::size_t size)
{
if (remaining() < size) {
return std::nullopt;
}
std::uint64_t value = 0;
for (std::size_t i = 0; i < size; ++i) {
value |= static_cast<std::uint64_t>(bytes_[offset_ + i]) << (8 * i);
}
offset_ += size;
return static_cast<T>(value);
}
std::span<const std::byte> bytes_;
std::size_t offset_ = 0;
};
// FNV-1a 64 bits : empreinte rapide et portable, pas une fonction cryptographique.
[[nodiscard]] inline std::uint64_t fnv1a64(std::span<const std::byte> bytes) noexcept
{
std::uint64_t hash = 0xcbf29ce484222325ull;
for (const std::byte b : bytes) {
hash ^= static_cast<std::uint64_t>(b);
hash *= 0x100000001b3ull;
}
return hash;
}
} // namespace sequa::core::sim::detail
+147
View File
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/core/sim/replay.hpp"
#include "sim/bytes.hpp"
#include <algorithm>
namespace sequa::core::sim {
namespace {
constexpr std::uint32_t recording_magic = 0x43525153u; // "SQRC" en petit-boutiste
constexpr std::uint32_t recording_version = 1;
constexpr std::size_t input_frame_size = 8 + 4 + 2 + 2;
} // namespace
bool Recording::is_valid() const noexcept
{
if (tick_rate_hz == 0) {
return false;
}
for (std::size_t i = 0; i < inputs.size(); ++i) {
const InputFrame& frame = inputs[i];
if (frame.tick >= tick_count || frame.is_neutral()) {
return false;
}
if (i > 0 && frame.tick <= inputs[i - 1].tick) {
return false;
}
}
return true;
}
std::vector<std::byte> serialize(const Recording& recording)
{
detail::ByteWriter w;
w.u32(recording_magic);
w.u32(recording_version);
w.u64(recording.seed);
w.u32(recording.tick_rate_hz);
w.u64(recording.tick_count);
w.u64(recording.inputs.size());
for (const InputFrame& frame : recording.inputs) {
w.u64(frame.tick);
w.u32(frame.buttons);
w.i16(frame.move_x);
w.i16(frame.move_y);
}
return w.take();
}
std::optional<Recording> deserialize_recording(std::span<const std::byte> bytes)
{
detail::ByteReader r(bytes);
if (r.u32() != recording_magic || r.u32() != recording_version) {
return std::nullopt;
}
const auto seed = r.u64();
const auto tick_rate = r.u32();
const auto tick_count = r.u64();
const auto input_count = r.u64();
if (!seed || !tick_rate || !tick_count || !input_count) {
return std::nullopt;
}
// La taille annoncée doit correspondre exactement aux octets restants : protège
// contre une allocation démesurée sur un fichier corrompu.
if (*input_count > r.remaining() / input_frame_size || r.remaining() != *input_count * input_frame_size) {
return std::nullopt;
}
Recording recording;
recording.seed = *seed;
recording.tick_rate_hz = *tick_rate;
recording.tick_count = *tick_count;
recording.inputs.reserve(static_cast<std::size_t>(*input_count));
for (std::uint64_t i = 0; i < *input_count; ++i) {
const auto tick = r.u64();
const auto buttons = r.u32();
const auto move_x = r.i16();
const auto move_y = r.i16();
if (!tick || !buttons || !move_x || !move_y) {
return std::nullopt;
}
recording.inputs.push_back(InputFrame{*tick, *buttons, *move_x, *move_y});
}
if (!recording.is_valid()) {
return std::nullopt;
}
return recording;
}
SessionRecorder::SessionRecorder(const SimConfig& config) : simulation_(config)
{
recording_.seed = config.seed;
recording_.tick_rate_hz = config.tick_rate_hz;
}
void SessionRecorder::step(InputFrame input)
{
input.tick = simulation_.tick();
if (!input.is_neutral()) {
recording_.inputs.push_back(input);
}
simulation_.step(input);
recording_.tick_count = simulation_.tick();
}
ReplayResult replay(const Recording& recording, const FeelParams& feel, const ArenaParams& arena)
{
SimConfig config;
config.seed = recording.seed;
config.tick_rate_hz = recording.tick_rate_hz;
config.feel = feel;
config.arena = arena;
Simulation simulation(config);
ReplayResult result;
result.hashes.reserve(static_cast<std::size_t>(recording.tick_count));
auto next_input = recording.inputs.begin();
for (Tick tick = 0; tick < recording.tick_count; ++tick) {
InputFrame input = neutral_input(tick);
if (next_input != recording.inputs.end() && next_input->tick == tick) {
input = *next_input++;
}
simulation.step(input);
result.hashes.push_back(simulation.state_hash());
}
result.final_world = simulation.world();
result.final_hash = simulation.state_hash();
return result;
}
std::optional<Tick> first_divergence(const ReplayResult& a, const ReplayResult& b) noexcept
{
const auto [ita, itb] = std::mismatch(a.hashes.begin(), a.hashes.end(), b.hashes.begin(), b.hashes.end());
if (ita == a.hashes.end() && itb == b.hashes.end()) {
return std::nullopt;
}
return static_cast<Tick>(ita - a.hashes.begin());
}
} // namespace sequa::core::sim
+153
View File
@@ -0,0 +1,153 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/core/sim/simulation.hpp"
#include "sim/bytes.hpp"
#include <limits>
namespace sequa::core::sim {
namespace {
constexpr std::uint64_t rng_stream = 0x5345'5155'4131ull; // "SEQUA1"
constexpr std::uint32_t state_format_version = 1;
[[nodiscard]] Fixed axis_to_unit(std::int16_t axis) noexcept
{
return Fixed::from_ratio(axis, 32767);
}
[[nodiscard]] Fixed min(Fixed a, Fixed b) noexcept
{
return b < a ? b : a;
}
[[nodiscard]] std::uint32_t saturating_increment(std::uint32_t value) noexcept
{
return value == std::numeric_limits<std::uint32_t>::max() ? value : value + 1;
}
} // namespace
Simulation::Simulation(const SimConfig& config)
: config_(config), dt_(Fixed::from_ratio(1, static_cast<std::int32_t>(config.tick_rate_hz))),
rng_(config.seed, rng_stream)
{
world_.coins.reserve(config_.arena.coin_count);
for (std::uint32_t i = 0; i < config_.arena.coin_count; ++i) {
world_.coins.push_back(spawn_coin());
}
}
Coin Simulation::spawn_coin()
{
const ArenaParams& arena = config_.arena;
Coin coin;
coin.id = world_.next_coin_id++;
coin.x = Fixed::from_raw(rng_.range((-arena.half_width).raw(), arena.half_width.raw()));
coin.y = Fixed::from_raw(rng_.range(arena.coin_min_height.raw(), arena.coin_max_height.raw()));
return coin;
}
void Simulation::step(const InputFrame& requested)
{
const InputFrame input = requested.tick == tick_ ? requested : neutral_input(tick_);
const FeelParams& feel = config_.feel;
const Fixed one = Fixed::from_int(1);
Body& p = world_.player;
// Horizontal : accélération vers la direction demandée, frottement au sol sans entrée.
const Fixed control = p.on_ground ? one : feel.air_control;
p.vx += axis_to_unit(input.move_x) * feel.move_acceleration * control * dt_;
if (input.move_x == 0 && p.on_ground) {
p.vx -= p.vx * min(feel.ground_friction * dt_, one);
}
p.vx = clamp(p.vx, -feel.max_speed, feel.max_speed);
// Saut sur front montant, avec mémoire d'appui et tolérance après le bord (coyote time).
const std::uint32_t pressed = input.buttons & ~p.previous_buttons;
p.previous_buttons = input.buttons;
if ((pressed & button::jump) != 0) {
p.jump_buffer = feel.jump_buffer_ticks + 1;
}
if (p.jump_buffer > 0) {
const bool can_jump = p.on_ground || p.ticks_since_ground <= feel.coyote_ticks;
if (can_jump) {
p.vy = feel.jump_velocity;
p.jump_buffer = 0;
p.on_ground = false;
p.ticks_since_ground = feel.coyote_ticks + 1; // pas de second saut en l'air
} else {
--p.jump_buffer;
}
}
// Intégration (Euler semi-implicite).
p.vy -= feel.gravity * dt_;
p.x += p.vx * dt_;
p.y += p.vy * dt_;
const ArenaParams& arena = config_.arena;
if (p.x < -arena.half_width || arena.half_width < p.x) {
p.x = clamp(p.x, -arena.half_width, arena.half_width);
p.vx = Fixed{};
}
if (p.y <= Fixed{}) {
p.y = Fixed{};
if (p.vy < Fixed{}) {
p.vy = Fixed{};
}
p.on_ground = true;
p.ticks_since_ground = 0;
} else {
p.on_ground = false;
p.ticks_since_ground = saturating_increment(p.ticks_since_ground);
}
// Règle du micro-mode : une pièce touchée rapporte un point et réapparaît ailleurs.
for (Coin& coin : world_.coins) {
if (abs(p.x - coin.x) <= arena.pickup_radius && abs(p.y - coin.y) <= arena.pickup_radius) {
++world_.score;
coin = spawn_coin();
}
}
++tick_;
}
std::vector<std::byte> Simulation::serialize_state() const
{
detail::ByteWriter w;
w.u32(state_format_version);
w.u64(tick_);
w.u64(rng_.state());
w.u64(rng_.increment());
const Body& p = world_.player;
w.i32(p.x.raw());
w.i32(p.y.raw());
w.i32(p.vx.raw());
w.i32(p.vy.raw());
w.u8(p.on_ground ? 1 : 0);
w.u32(p.ticks_since_ground);
w.u32(p.jump_buffer);
w.u32(p.previous_buttons);
w.u32(world_.score);
w.u32(world_.next_coin_id);
w.u32(static_cast<std::uint32_t>(world_.coins.size()));
for (const Coin& coin : world_.coins) {
w.u32(coin.id);
w.i32(coin.x.raw());
w.i32(coin.y.raw());
}
return w.take();
}
std::uint64_t Simulation::state_hash() const
{
const std::vector<std::byte> bytes = serialize_state();
return detail::fnv1a64(bytes);
}
} // namespace sequa::core::sim
+42
View File
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MPL-2.0
// Session de test scriptée : un « joueur » qui court, change de direction et saute.
#pragma once
#include "sequa/core/sim/input.hpp"
#include "sequa/core/sim/replay.hpp"
#include <cstdint>
namespace sequa::core::sim::testing {
[[nodiscard]] inline InputFrame scripted_input(Tick tick)
{
InputFrame input = neutral_input(tick);
const Tick phase = tick % 240;
if (phase < 100) {
input.move_x = 32767;
} else if (phase < 140) {
input.move_x = 0;
} else {
input.move_x = -20000;
}
if (tick % 45 < 3) {
input.buttons = button::jump;
}
return input;
}
[[nodiscard]] inline Recording record_scripted_session(std::uint64_t seed, Tick ticks,
const FeelParams& feel = FeelParams{})
{
SimConfig config;
config.seed = seed;
config.feel = feel;
SessionRecorder recorder(config);
for (Tick tick = 0; tick < ticks; ++tick) {
recorder.step(scripted_input(tick));
}
return recorder.recording();
}
} // namespace sequa::core::sim::testing
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/core/sim/clock.hpp"
#include <doctest/doctest.h>
#include <cstdint>
using sequa::core::sim::FixedStepClock;
TEST_CASE("FixedStepClock : 60 Hz exacts sur une seconde decoupee irregulierement")
{
FixedStepClock clock(60, 1000);
std::uint64_t steps = 0;
// 1 s = 7 tranches de 142 857 142 ns + reste : aucun pas perdu ni gagne.
for (int i = 0; i < 7; ++i) {
steps += clock.advance(142'857'142);
}
steps += clock.advance(1'000'000'000ull - 7ull * 142'857'142ull);
CHECK(steps == 60);
}
TEST_CASE("FixedStepClock : 144 Hz d'affichage, 60 Hz de simulation")
{
FixedStepClock clock(60, 1000);
std::uint64_t steps = 0;
const std::uint64_t frame_ns = 1'000'000'000ull / 144; // arrondi : 6 944 444 ns
for (int frame = 0; frame < 144 * 10; ++frame) {
steps += clock.advance(frame_ns);
}
// 1440 images * 6 944 444 ns = 9,99999936 s -> 599 pas complets.
CHECK(steps == 599);
}
TEST_CASE("FixedStepClock : un gros retard est plafonne et compte")
{
FixedStepClock clock(60, 8);
CHECK(clock.advance(1'000'000'000ull) == 8);
CHECK(clock.dropped_steps() == 52);
CHECK(clock.advance(0) == 0);
}
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/core/sim/fixed.hpp"
#include <doctest/doctest.h>
#include <cstdint>
#include <limits>
using sequa::core::sim::Fixed;
TEST_CASE("Fixed : conversions entieres et rationnelles")
{
CHECK(Fixed::from_int(3).raw() == 3 * 65536);
CHECK(Fixed::from_int(-2).raw() == -2 * 65536);
CHECK(Fixed::from_ratio(1, 2).raw() == 32768);
CHECK(Fixed::from_ratio(1, 60).raw() == 1092); // arrondi vers zero
CHECK(Fixed::from_ratio(-1, 60).raw() == -1092);
}
TEST_CASE("Fixed : arithmetique exacte et arrondis documentes")
{
const Fixed a = Fixed::from_int(5);
const Fixed b = Fixed::from_ratio(1, 4);
CHECK((a + b).raw() == 5 * 65536 + 16384);
CHECK((a - b).raw() == 5 * 65536 - 16384);
CHECK((a * b).raw() == 5 * 16384);
CHECK((a / b).raw() == 20 * 65536);
CHECK((-a).raw() == -5 * 65536);
// Produit : arrondi vers moins l'infini ; quotient : vers zero.
const Fixed tiny = Fixed::from_raw(1);
const Fixed half = Fixed::from_ratio(1, 2);
CHECK((tiny * half).raw() == 0);
CHECK(((-tiny) * half).raw() == -1);
CHECK((Fixed::from_raw(-1) / Fixed::from_int(2)).raw() == 0);
}
TEST_CASE("Fixed : un depassement est modulaire, donc deterministe")
{
const Fixed max = Fixed::from_raw(std::numeric_limits<std::int32_t>::max());
CHECK((max + Fixed::from_raw(1)).raw() == std::numeric_limits<std::int32_t>::min());
}
TEST_CASE("Fixed : comparaisons, abs et clamp")
{
const Fixed one = Fixed::from_int(1);
CHECK(one > Fixed{});
CHECK(abs(-one) == one);
CHECK(clamp(Fixed::from_int(7), -one, one) == one);
CHECK(clamp(Fixed::from_int(-7), -one, one) == -one);
CHECK(Fixed::from_ratio(3, 2).to_double() == doctest::Approx(1.5));
}
+108
View File
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/core/sim/replay.hpp"
#include "scripted_session.hpp"
#include <doctest/doctest.h>
#include <cstddef>
#include <cstdint>
#include <utility>
#include <vector>
using namespace sequa::core::sim;
namespace {
constexpr Tick session_ticks = 60 * 60; // une minute a 60 Hz
} // namespace
TEST_CASE("Replay : le rejeu est identique bit a bit a la session enregistree")
{
SimConfig config;
config.seed = 2026;
SessionRecorder recorder(config);
std::vector<std::uint64_t> live_hashes;
for (Tick tick = 0; tick < session_ticks; ++tick) {
recorder.step(testing::scripted_input(tick));
live_hashes.push_back(recorder.simulation().state_hash());
}
const Recording& recording = recorder.recording();
REQUIRE(recording.is_valid());
CHECK(recording.tick_count == session_ticks);
CHECK(recording.inputs.size() < session_ticks); // les ticks neutres ne sont pas stockes
const ReplayResult result = replay(recording, config.feel);
CHECK(result.hashes == live_hashes);
CHECK(result.final_hash == recorder.simulation().state_hash());
}
TEST_CASE("Replay : meme session, autre reglage de ressenti, divergence localisee")
{
const Recording recording = testing::record_scripted_session(7, session_ticks);
const ReplayResult reference = replay(recording, FeelParams{});
FeelParams floaty;
floaty.jump_velocity = Fixed::from_int(14);
const ReplayResult variant = replay(recording, floaty);
const auto divergence = first_divergence(reference, variant);
REQUIRE(divergence.has_value());
// Le premier saut a lieu au tick 0 : les deux versions divergent des ce tick.
CHECK(*divergence == 0);
CHECK(first_divergence(reference, replay(recording, FeelParams{})) == std::nullopt);
}
TEST_CASE("Replay : serialisation aller-retour exacte")
{
const Recording recording = testing::record_scripted_session(11, session_ticks);
const std::vector<std::byte> bytes = serialize(recording);
const auto loaded = deserialize_recording(bytes);
REQUIRE(loaded.has_value());
CHECK(*loaded == recording);
CHECK(replay(*loaded, FeelParams{}).final_hash == replay(recording, FeelParams{}).final_hash);
}
TEST_CASE("Replay : un enregistrement corrompu est refuse")
{
const Recording recording = testing::record_scripted_session(11, 600);
const std::vector<std::byte> bytes = serialize(recording);
SUBCASE("tronque")
{
const std::vector<std::byte> truncated(bytes.begin(), bytes.end() - 1);
CHECK_FALSE(deserialize_recording(truncated).has_value());
}
SUBCASE("mauvais magique")
{
std::vector<std::byte> bad = bytes;
bad[0] = std::byte{0};
CHECK_FALSE(deserialize_recording(bad).has_value());
}
SUBCASE("ticks desordonnes")
{
Recording unordered = recording;
REQUIRE(unordered.inputs.size() >= 2);
std::swap(unordered.inputs[0], unordered.inputs[1]);
CHECK_FALSE(deserialize_recording(serialize(unordered)).has_value());
}
SUBCASE("vide")
{
CHECK_FALSE(deserialize_recording({}).has_value());
}
}
TEST_CASE("Replay : empreinte de reference multiplateforme")
{
// Valeur figee : toute plateforme ou tout compilateur doit retrouver exactement cette
// empreinte (docs, §10.1 et §16). Si elle change, c'est soit une modification voulue
// de la simulation (mettre a jour la valeur et le format d'etat), soit une perte de
// determinisme a corriger.
const Recording recording = testing::record_scripted_session(2026, session_ticks);
const ReplayResult result = replay(recording, FeelParams{});
MESSAGE("empreinte finale : " << result.final_hash << ", score " << result.final_world.score);
CHECK(result.final_hash == 15306857814008723692ull);
CHECK(result.final_world.score == 6);
}
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/core/sim/rng.hpp"
#include <doctest/doctest.h>
#include <array>
#include <cstdint>
using sequa::core::sim::Pcg32;
TEST_CASE("Pcg32 : sorties de reference de pcg32-demo (graine 42, flux 54)")
{
Pcg32 rng(42, 54);
const std::array<std::uint32_t, 6> expected{0xa15c02b7u, 0x7b47f409u, 0xba1d3330u,
0x83d2f293u, 0xbfa4784bu, 0xcbed606eu};
for (const std::uint32_t value : expected) {
CHECK(rng.next() == value);
}
}
TEST_CASE("Pcg32 : meme graine, meme suite ; graine differente, suite differente")
{
Pcg32 a(7, 1);
Pcg32 b(7, 1);
Pcg32 c(8, 1);
bool any_difference = false;
for (int i = 0; i < 100; ++i) {
const std::uint32_t va = a.next();
CHECK(va == b.next());
any_difference = any_difference || va != c.next();
}
CHECK(any_difference);
}
TEST_CASE("Pcg32 : bounded et range restent dans leurs bornes")
{
Pcg32 rng(123, 9);
for (int i = 0; i < 10'000; ++i) {
CHECK(rng.bounded(10) < 10u);
const std::int32_t v = rng.range(-5, 5);
CHECK(v >= -5);
CHECK(v <= 5);
}
CHECK(rng.range(3, 3) == 3);
}
+84
View File
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/core/sim/simulation.hpp"
#include "scripted_session.hpp"
#include <doctest/doctest.h>
using namespace sequa::core::sim;
namespace {
SimConfig config_with_seed(std::uint64_t seed)
{
SimConfig config;
config.seed = seed;
return config;
}
} // namespace
TEST_CASE("Simulation : deux instances identiques restent identiques bit a bit")
{
Simulation a(config_with_seed(42));
Simulation b(config_with_seed(42));
CHECK(a.state_hash() == b.state_hash());
for (Tick tick = 0; tick < 3600; ++tick) {
const InputFrame input = testing::scripted_input(tick);
a.step(input);
b.step(input);
REQUIRE(a.state_hash() == b.state_hash());
}
CHECK(a.serialize_state() == b.serialize_state());
}
TEST_CASE("Simulation : la graine change le placement des pieces")
{
const Simulation a(config_with_seed(1));
const Simulation b(config_with_seed(2));
CHECK(a.state_hash() != b.state_hash());
}
TEST_CASE("Simulation : une entree hors sequence est neutralisee")
{
Simulation a(config_with_seed(5));
Simulation b(config_with_seed(5));
InputFrame wrong_tick = testing::scripted_input(0);
wrong_tick.tick = 99;
a.step(wrong_tick);
b.step(neutral_input(0));
CHECK(a.state_hash() == b.state_hash());
}
TEST_CASE("Simulation : le joueur tombe, atterrit et saute")
{
Simulation sim(config_with_seed(3));
CHECK(sim.world().player.on_ground);
InputFrame jump = neutral_input(0);
jump.buttons = button::jump;
sim.step(jump);
CHECK_FALSE(sim.world().player.on_ground);
CHECK(sim.world().player.y > Fixed{});
// Bouton maintenu : un seul saut (front montant), puis retour au sol.
for (Tick tick = 1; tick < 120; ++tick) {
InputFrame held = neutral_input(tick);
held.buttons = button::jump;
sim.step(held);
}
CHECK(sim.world().player.on_ground);
CHECK(sim.world().player.y == Fixed{});
}
TEST_CASE("Simulation : le joueur ne sort pas de l'arene")
{
Simulation sim(config_with_seed(4));
for (Tick tick = 0; tick < 1200; ++tick) {
InputFrame right = neutral_input(tick);
right.move_x = 32767;
sim.step(right);
}
CHECK(sim.world().player.x == sim.config().arena.half_width);
CHECK(abs(sim.world().player.vx) <= sim.config().feel.max_speed);
}