diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index a5a07cc..3c11bb9 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -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) diff --git a/core/README.md b/core/README.md index f1de7cb..ef95a82 100644 --- a/core/README.md +++ b/core/README.md @@ -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 diff --git a/core/include/sequa/core/sim/clock.hpp b/core/include/sequa/core/sim/clock.hpp new file mode 100644 index 0000000..0910605 --- /dev/null +++ b/core/include/sequa/core/sim/clock.hpp @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include + +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(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 diff --git a/core/include/sequa/core/sim/fixed.hpp b/core/include/sequa/core/sim/fixed.hpp new file mode 100644 index 0000000..6519398 --- /dev/null +++ b/core/include/sequa/core/sim/fixed.hpp @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include +#include + +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(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(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(raw_) / one_raw; } + + friend constexpr Fixed operator+(Fixed a, Fixed b) noexcept + { + return from_raw(narrow(static_cast(a.raw_) + b.raw_)); + } + friend constexpr Fixed operator-(Fixed a, Fixed b) noexcept + { + return from_raw(narrow(static_cast(a.raw_) - b.raw_)); + } + friend constexpr Fixed operator-(Fixed a) noexcept { return from_raw(narrow(-static_cast(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(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(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(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 diff --git a/core/include/sequa/core/sim/input.hpp b/core/include/sequa/core/sim/input.hpp new file mode 100644 index 0000000..3f49dd0 --- /dev/null +++ b/core/include/sequa/core/sim/input.hpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include + +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 diff --git a/core/include/sequa/core/sim/replay.hpp b/core/include/sequa/core/sim/replay.hpp new file mode 100644 index 0000000..8597322 --- /dev/null +++ b/core/include/sequa/core/sim/replay.hpp @@ -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 +#include +#include +#include +#include + +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 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 serialize(const Recording& recording); +// Retourne std::nullopt si les octets sont tronqués, d'une autre version ou incohérents. +[[nodiscard]] std::optional deserialize_recording(std::span 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 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 first_divergence(const ReplayResult& a, const ReplayResult& b) noexcept; + +} // namespace sequa::core::sim diff --git a/core/include/sequa/core/sim/rng.hpp b/core/include/sequa/core/sim/rng.hpp new file mode 100644 index 0000000..8e591f7 --- /dev/null +++ b/core/include/sequa/core/sim/rng.hpp @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include + +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(((old >> 18u) ^ old) >> 27u); + const auto rotation = static_cast(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::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(static_cast(high) - low + 1); + if (span == 0) { // intervalle complet des int32 + return static_cast(next()); + } + return static_cast(static_cast(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 diff --git a/core/include/sequa/core/sim/simulation.hpp b/core/include/sequa/core/sim/simulation.hpp new file mode 100644 index 0000000..b571c6c --- /dev/null +++ b/core/include/sequa/core/sim/simulation.hpp @@ -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 +#include +#include + +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 serialize_state() const; + +private: + Coin spawn_coin(); + + SimConfig config_; + Fixed dt_; + Pcg32 rng_; + WorldState world_; + Tick tick_ = 0; +}; + +} // namespace sequa::core::sim diff --git a/core/include/sequa/core/sim/world.hpp b/core/include/sequa/core/sim/world.hpp new file mode 100644 index 0000000..bdf5baf --- /dev/null +++ b/core/include/sequa/core/sim/world.hpp @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MPL-2.0 +#pragma once + +#include "sequa/core/sim/fixed.hpp" + +#include +#include + +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 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 diff --git a/core/src/sim/bytes.hpp b/core/src/sim/bytes.hpp new file mode 100644 index 0000000..2de4870 --- /dev/null +++ b/core/src/sim/bytes.hpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MPL-2.0 +// Encodage binaire canonique petit-boutiste, interne à sequa_core. +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace sequa::core::sim::detail { + +class ByteWriter { +public: + void u8(std::uint8_t value) { bytes_.push_back(static_cast(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(value)); } + void i32(std::int32_t value) { u32(static_cast(value)); } + + [[nodiscard]] std::vector 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((value >> (8 * i)) & 0xFFu)); + } + } + + std::vector bytes_; +}; + +class ByteReader { +public: + explicit ByteReader(std::span bytes) noexcept : bytes_(bytes) {} + + [[nodiscard]] std::optional u8() { return get(1); } + [[nodiscard]] std::optional u16() { return get(2); } + [[nodiscard]] std::optional u32() { return get(4); } + [[nodiscard]] std::optional u64() { return get(8); } + + [[nodiscard]] std::optional i16() + { + const auto value = u16(); + return value ? std::optional{static_cast(*value)} : std::nullopt; + } + + [[nodiscard]] std::size_t remaining() const noexcept { return bytes_.size() - offset_; } + +private: + template + [[nodiscard]] std::optional 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(bytes_[offset_ + i]) << (8 * i); + } + offset_ += size; + return static_cast(value); + } + + std::span 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 bytes) noexcept +{ + std::uint64_t hash = 0xcbf29ce484222325ull; + for (const std::byte b : bytes) { + hash ^= static_cast(b); + hash *= 0x100000001b3ull; + } + return hash; +} + +} // namespace sequa::core::sim::detail diff --git a/core/src/sim/replay.cpp b/core/src/sim/replay.cpp new file mode 100644 index 0000000..d183c29 --- /dev/null +++ b/core/src/sim/replay.cpp @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/core/sim/replay.hpp" + +#include "sim/bytes.hpp" + +#include + +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 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 deserialize_recording(std::span 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(*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(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 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(ita - a.hashes.begin()); +} + +} // namespace sequa::core::sim diff --git a/core/src/sim/simulation.cpp b/core/src/sim/simulation.cpp new file mode 100644 index 0000000..dc6cc3d --- /dev/null +++ b/core/src/sim/simulation.cpp @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/core/sim/simulation.hpp" + +#include "sim/bytes.hpp" + +#include + +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::max() ? value : value + 1; +} + +} // namespace + +Simulation::Simulation(const SimConfig& config) + : config_(config), dt_(Fixed::from_ratio(1, static_cast(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 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(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 bytes = serialize_state(); + return detail::fnv1a64(bytes); +} + +} // namespace sequa::core::sim diff --git a/core/tests/sim/scripted_session.hpp b/core/tests/sim/scripted_session.hpp new file mode 100644 index 0000000..0127e58 --- /dev/null +++ b/core/tests/sim/scripted_session.hpp @@ -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 + +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 diff --git a/core/tests/sim/test_clock.cpp b/core/tests/sim/test_clock.cpp new file mode 100644 index 0000000..bb45a6e --- /dev/null +++ b/core/tests/sim/test_clock.cpp @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/core/sim/clock.hpp" + +#include + +#include + +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); +} diff --git a/core/tests/sim/test_fixed.cpp b/core/tests/sim/test_fixed.cpp new file mode 100644 index 0000000..0d06b4d --- /dev/null +++ b/core/tests/sim/test_fixed.cpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/core/sim/fixed.hpp" + +#include + +#include +#include + +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::max()); + CHECK((max + Fixed::from_raw(1)).raw() == std::numeric_limits::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)); +} diff --git a/core/tests/sim/test_replay.cpp b/core/tests/sim/test_replay.cpp new file mode 100644 index 0000000..f3fa1e1 --- /dev/null +++ b/core/tests/sim/test_replay.cpp @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/core/sim/replay.hpp" + +#include "scripted_session.hpp" + +#include + +#include +#include +#include +#include + +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 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 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 bytes = serialize(recording); + + SUBCASE("tronque") + { + const std::vector truncated(bytes.begin(), bytes.end() - 1); + CHECK_FALSE(deserialize_recording(truncated).has_value()); + } + SUBCASE("mauvais magique") + { + std::vector 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); +} diff --git a/core/tests/sim/test_rng.cpp b/core/tests/sim/test_rng.cpp new file mode 100644 index 0000000..83e428b --- /dev/null +++ b/core/tests/sim/test_rng.cpp @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/core/sim/rng.hpp" + +#include + +#include +#include + +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 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); +} diff --git a/core/tests/sim/test_simulation.cpp b/core/tests/sim/test_simulation.cpp new file mode 100644 index 0000000..a081147 --- /dev/null +++ b/core/tests/sim/test_simulation.cpp @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MPL-2.0 +#include "sequa/core/sim/simulation.hpp" + +#include "scripted_session.hpp" + +#include + +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); +}