feat(input): entrees du joueur captees par l'adaptateur (ADR 0011)

L'adaptateur traduit clavier et manette en actions SEQUA (PlayerInput) ;
le Core ne recoit et n'enregistre que ces actions, le rejeu reste exact.

- SEQUA 3D : PlayerInput, poll_input(), capacite player_input
  (interface des adaptateurs en version 2)
- protocole Core - rendu 0.2 : render/input, envoye quand les actions
  changent ; entree remise au neutre si le rendu disparait
- Godot : InputMap du projet (ZQSD, fleches, espace, manette) ou script
  SequaInput.sequa_player_input() pour une correspondance avancee ;
  entree neutre sans focus ; exemple examples/sequa_input.gd
- adaptateur debug : entrees scriptees par numero d'image
- Studio : ligne "Entrees du joueur" du panneau Rendu
This commit is contained in:
Ange-Marie MAURIN - A2M Systèmes committed 2026-11-07 20:48:26 +01:00
1 parent f9e41298a3
commit 98c778aa28
52 files changed
+1241 -37

No files matched your search

+2
View File
@@ -6,6 +6,7 @@
add_library(sequa_3d
src/adapter.cpp
src/capabilities.cpp
src/input.cpp
src/null_adapter.cpp
src/scene.cpp)
add_library(sequa::3d ALIAS sequa_3d)
@@ -18,6 +19,7 @@ if(SEQUA_BUILD_TESTS)
add_executable(sequa_3d_tests
tests/main.cpp
tests/test_capabilities.cpp
tests/test_input.cpp
tests/test_scene.cpp
tests/test_null_adapter.cpp)
target_link_libraries(sequa_3d_tests PRIVATE sequa::3d doctest::doctest)
+2 -1
View File
@@ -24,10 +24,11 @@ Phase 0. Bibliothèque `sequa::3d` (`include/sequa/3d/`, espace de noms `sequa::
| Élément | Fichier | Rôle |
|---|---|---|
| `I3DEngineAdapter`, `EngineConfig`, `validate_request` | `adapter.hpp` | Interface que chaque moteur implémente : capacités, démarrage, deltas de scène, images |
| `I3DEngineAdapter`, `EngineConfig`, `validate_request` | `adapter.hpp` | Interface que chaque moteur implémente : capacités, démarrage, deltas de scène, images, entrées du joueur |
| `FrameBuffer`, `BufferSet`, `EngineCapabilities`, `negotiate` | `capabilities.hpp` | Négociation des capacités : tampons accordés et manquants |
| `SceneDelta`, `SceneCommand`, `SceneState` | `scene.hpp` | Synchronisation à sens unique, versions croissantes, tout ou rien, snapshot pour (re)démarrer un moteur |
| `FrameRequest`, `FrameResult`, `FrameMeasurements` | `frame.hpp` | Demande d'image et mesures dans un format commun |
| `PlayerInput`, `quantize_axis` | `input.hpp` | Entrées du joueur déjà traduites en actions par l'adaptateur (`poll_input`, [ADR 0011](../docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md)) |
| `NullEngineAdapter` | `null_adapter.hpp` | **SIMULÉ** : adaptateur sans moteur, pour les tests et comme référence de comportement |
**PROVISOIRE** : types C++ écrits à la main, en attendant le format du contrat (Protobuf ou FlatBuffers, voir [schema/](../schema)). Les tampons GPU eux-mêmes n'y sont pas encore : ils arriveront après le prototype d'échange GPU entre rendu et inférence (§5.6). Les visuels se limitent pour l'instant à des primitives.
+9 -2
View File
@@ -4,6 +4,7 @@
#include "sequa/3d/capabilities.hpp"
#include "sequa/3d/error.hpp"
#include "sequa/3d/frame.hpp"
#include "sequa/3d/input.hpp"
#include "sequa/3d/scene.hpp"
#include <cstdint>
@@ -27,8 +28,8 @@ using FrameOutcome = std::variant<FrameResult, Error>;
// Godot, Unreal ou autre. Ce qu'un moteur ne sait pas faire est déclaré par
// capabilities(), jamais retiré de l'interface.
//
// Cycle : capabilities() (possible à tout moment) → start() → apply()/render_frame()… →
// stop(). Après start(), la scène du moteur est vide : l'appelant pousse un snapshot.
// Cycle : capabilities() (possible à tout moment) → start() → apply()/render_frame()/
// poll_input()… → stop(). Après start(), la scène du moteur est vide : l'appelant pousse un snapshot.
class I3DEngineAdapter {
public:
I3DEngineAdapter() = default;
@@ -49,6 +50,12 @@ public:
// Les tampons demandés doivent faire partie des capacités déclarées (sinon unsupported).
[[nodiscard]] virtual FrameOutcome render_frame(const FrameRequest& request) = 0;
// Entrées du joueur captées pendant la dernière image, déjà traduites en actions par
// l'adaptateur (ADR 0011), à appeler après render_frame(). Neutre quand la fenêtre n'a pas
// le focus ; std::nullopt si le moteur ne capte pas d'entrées (capabilities().player_input
// faux) ou s'il est arrêté.
[[nodiscard]] virtual std::optional<PlayerInput> poll_input() = 0;
};
// Vérifications communes à tous les adaptateurs avant de transmettre une requête au moteur.
+1 -1
View File
@@ -14,7 +14,7 @@
namespace sequa::three_d {
// À incrémenter à chaque changement de I3DEngineAdapter ou des types du contrat.
inline constexpr std::uint32_t adapter_interface_version = 1;
inline constexpr std::uint32_t adapter_interface_version = 2; // 2 : poll_input (ADR 0011)
inline constexpr const char* create_adapter_symbol = "sequa_create_engine_adapter";
inline constexpr const char* destroy_adapter_symbol = "sequa_destroy_engine_adapter";
+1
View File
@@ -89,6 +89,7 @@ struct EngineCapabilities {
bool headless = false; // rendu hors écran (tests, génération de données)
std::uint32_t max_width = 0;
std::uint32_t max_height = 0;
bool player_input = false; // capte les entrées du joueur (poll_input, ADR 0011)
};
struct Negotiation {
+29
View File
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include <cstdint>
namespace sequa::three_d {
// Entrées du joueur déjà traduites en actions SEQUA par l'adaptateur (ADR 0011) : les
// appareils (clavier, manette…) et leur correspondance restent dans l'adaptateur ; le Core
// ne reçoit et n'enregistre que ces actions, entières pour que le rejeu soit exact (§10.1).
// Même forme que sim::InputFrame ; la signification des bits appartient au mode de jeu du
// Core (aujourd'hui : bit 0 = saut).
struct PlayerInput {
std::uint32_t buttons = 0;
std::int16_t move_x = 0; // [-axis_max, axis_max] : gauche, droite
std::int16_t move_y = 0; // [-axis_max, axis_max] : bas (ou arrière), haut (ou avant)
[[nodiscard]] constexpr bool is_neutral() const noexcept { return buttons == 0 && move_x == 0 && move_y == 0; }
friend constexpr bool operator==(const PlayerInput&, const PlayerInput&) noexcept = default;
};
inline constexpr std::int16_t axis_max = 32767;
// Axe analogique [-1, 1] vers l'entier du contrat : borné, arrondi au plus proche ; une
// valeur non finie (NaN, infini) donne 0.
[[nodiscard]] std::int16_t quantize_axis(double value) noexcept;
} // namespace sequa::three_d
+4
View File
@@ -21,12 +21,16 @@ public:
[[nodiscard]] std::optional<Error> apply(const SceneDelta& delta) override;
[[nodiscard]] FrameOutcome render_frame(const FrameRequest& request) override;
// Renvoie l'entrée fixée par set_input() si capabilities().player_input, sinon nullopt.
[[nodiscard]] std::optional<PlayerInput> poll_input() override;
[[nodiscard]] const SceneState& scene() const noexcept { return scene_; }
void set_input(PlayerInput input) noexcept { input_ = input; }
private:
EngineCapabilities capabilities_;
SceneState scene_;
PlayerInput input_;
bool running_ = false;
};
+17
View File
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/3d/input.hpp"
#include <cmath>
namespace sequa::three_d {
std::int16_t quantize_axis(double value) noexcept
{
if (!std::isfinite(value)) {
return 0;
}
const double clamped = value < -1.0 ? -1.0 : (value > 1.0 ? 1.0 : value);
return static_cast<std::int16_t>(std::lround(clamped * axis_max));
}
} // namespace sequa::three_d
+8
View File
@@ -53,4 +53,12 @@ FrameOutcome NullEngineAdapter::render_frame(const FrameRequest& request)
return FrameResult{request.frame_index, scene_.world_version(), request.buffers, {}};
}
std::optional<PlayerInput> NullEngineAdapter::poll_input()
{
if (!running_ || !capabilities_.player_input) {
return std::nullopt;
}
return input_;
}
} // namespace sequa::three_d
+52
View File
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/3d/input.hpp"
#include "sequa/3d/null_adapter.hpp"
#include <doctest/doctest.h>
#include <limits>
using namespace sequa::three_d;
TEST_CASE("quantize_axis : bornes, arrondi, valeurs non finies")
{
CHECK(quantize_axis(0.0) == 0);
CHECK(quantize_axis(1.0) == axis_max);
CHECK(quantize_axis(-1.0) == -axis_max);
CHECK(quantize_axis(0.5) == 16384); // 16383,5 arrondi au plus proche
CHECK(quantize_axis(-0.5) == -16384);
CHECK(quantize_axis(3.0) == axis_max);
CHECK(quantize_axis(-3.0) == -axis_max);
CHECK(quantize_axis(std::numeric_limits<double>::quiet_NaN()) == 0);
CHECK(quantize_axis(std::numeric_limits<double>::infinity()) == 0);
}
TEST_CASE("PlayerInput : neutre et egalite")
{
CHECK(PlayerInput{}.is_neutral());
CHECK_FALSE(PlayerInput{1, 0, 0}.is_neutral());
CHECK_FALSE(PlayerInput{0, 0, -5}.is_neutral());
CHECK(PlayerInput{1, 2, 3} == PlayerInput{1, 2, 3});
CHECK_FALSE(PlayerInput{1, 2, 3} == PlayerInput{1, 2, 4});
}
TEST_CASE("NullEngineAdapter : entrees seulement si declarees et demarre")
{
NullEngineAdapter silent;
EngineConfig config;
config.headless = true;
REQUIRE_FALSE(silent.start(config));
CHECK_FALSE(silent.poll_input().has_value()); // capacite non declaree
EngineCapabilities caps{"null", "0", BufferSet::all(), false, true, 0, 0};
caps.player_input = true;
NullEngineAdapter adapter(caps);
CHECK_FALSE(adapter.poll_input().has_value()); // pas encore demarre
REQUIRE_FALSE(adapter.start(config));
REQUIRE(adapter.poll_input().has_value());
CHECK(adapter.poll_input()->is_neutral());
adapter.set_input(PlayerInput{1, axis_max, 0});
CHECK(*adapter.poll_input() == PlayerInput{1, axis_max, 0});
adapter.stop();
CHECK_FALSE(adapter.poll_input().has_value());
}
+14 -1
View File
@@ -2,6 +2,7 @@
#pragma once
#include "sequa/3d/capabilities.hpp"
#include "sequa/3d/input.hpp"
#include "sequa/3d/scene.hpp"
#include <nlohmann/json.hpp>
@@ -50,9 +51,21 @@ struct SceneDeltaMessage {
[[nodiscard]] std::optional<SceneDeltaMessage> decode_scene_delta(const nlohmann::json& payload,
std::string* error = nullptr);
// Capacités du moteur : { engineName, engineVersion, buffers: [noms], windowed, headless }.
// Capacités du moteur : { engineName, engineVersion, buffers: [noms], windowed, headless,
// playerInput } ; playerInput est facultatif au décodage (faux par défaut).
[[nodiscard]] nlohmann::json encode_capabilities(const EngineCapabilities& caps);
[[nodiscard]] std::optional<EngineCapabilities> decode_capabilities(const nlohmann::json& value,
std::string* error = nullptr);
// Charge utile du message `render/input` (protocole 0.2, ADR 0011) :
// { buttons: int [0, 2^32-1], moveX: int, moveY: int ([-32767, 32767]), capturedAtUs: hex }.
struct PlayerInputMessage {
PlayerInput input;
std::uint64_t captured_at_us = 0; // horloge monotone du rendu, comme sentAtUs
};
[[nodiscard]] nlohmann::json encode_player_input(const PlayerInputMessage& message);
[[nodiscard]] std::optional<PlayerInputMessage> decode_player_input(const nlohmann::json& payload,
std::string* error = nullptr);
} // namespace sequa::three_d::wire
+77
View File
@@ -504,6 +504,7 @@ nlohmann::json encode_capabilities(const EngineCapabilities& caps)
out["buffers"] = std::move(buffers);
out["windowed"] = caps.windowed;
out["headless"] = caps.headless;
out["playerInput"] = caps.player_input;
return out;
}
@@ -560,7 +561,83 @@ std::optional<EngineCapabilities> decode_capabilities(const nlohmann::json& valu
}
caps.windowed = value["windowed"].get<bool>();
caps.headless = value["headless"].get<bool>();
if (value.contains("playerInput")) {
if (!value["playerInput"].is_boolean()) {
refuse(error, "playerInput : booléen attendu");
return std::nullopt;
}
caps.player_input = value["playerInput"].get<bool>();
}
return caps;
}
nlohmann::json encode_player_input(const PlayerInputMessage& message)
{
json out;
out["buttons"] = message.input.buttons;
out["moveX"] = message.input.move_x;
out["moveY"] = message.input.move_y;
out["capturedAtUs"] = to_hex64(message.captured_at_us);
return out;
}
std::optional<PlayerInputMessage> decode_player_input(const nlohmann::json& payload, std::string* error)
{
if (!payload.is_object()) {
refuse(error, "render/input : objet attendu");
return std::nullopt;
}
// Entier JSON dans [low, high] (les flottants, même entiers, sont refusés).
const auto integer = [&](const char* key, std::int64_t low, std::int64_t high) -> std::optional<std::int64_t> {
if (!payload.contains(key)) {
refuse(error, std::string(key) + " manquant");
return std::nullopt;
}
const json& field = payload[key];
std::int64_t raw = 0;
if (field.is_number_unsigned()) {
const std::uint64_t value = field.get<std::uint64_t>();
raw = value > static_cast<std::uint64_t>(high) ? high + 1 : static_cast<std::int64_t>(value);
} else if (field.is_number_integer()) {
raw = field.get<std::int64_t>();
} else {
refuse(error, std::string(key) + " : entier attendu");
return std::nullopt;
}
if (raw < low || raw > high) {
refuse(error, std::string(key) + " : hors de [" + std::to_string(low) + ", " + std::to_string(high) + "]");
return std::nullopt;
}
return raw;
};
const auto buttons = integer("buttons", 0, 0xFFFFFFFFLL);
if (!buttons) {
return std::nullopt;
}
const auto move_x = integer("moveX", -axis_max, axis_max);
if (!move_x) {
return std::nullopt;
}
const auto move_y = integer("moveY", -axis_max, axis_max);
if (!move_y) {
return std::nullopt;
}
if (!payload.contains("capturedAtUs") || !payload["capturedAtUs"].is_string()) {
refuse(error, "capturedAtUs : chaîne hexadécimale attendue");
return std::nullopt;
}
const std::optional<std::uint64_t> captured = from_hex64(payload["capturedAtUs"].get<std::string>());
if (!captured) {
refuse(error, "capturedAtUs : hexadécimal invalide");
return std::nullopt;
}
PlayerInputMessage message;
message.input.buttons = static_cast<std::uint32_t>(*buttons);
message.input.move_x = static_cast<std::int16_t>(*move_x);
message.input.move_y = static_cast<std::int16_t>(*move_y);
message.captured_at_us = *captured;
return message;
}
} // namespace sequa::three_d::wire
+61 -1
View File
@@ -27,7 +27,8 @@ namespace {
[[nodiscard]] bool capabilities_equal(const EngineCapabilities& left, const EngineCapabilities& right)
{
return left.engine_name == right.engine_name && left.engine_version == right.engine_version &&
left.buffers == right.buffers && left.windowed == right.windowed && left.headless == right.headless;
left.buffers == right.buffers && left.windowed == right.windowed && left.headless == right.headless &&
left.player_input == right.player_input;
}
Transform sample_transform()
@@ -385,6 +386,65 @@ TEST_CASE("Wire : refus d'un tampon inconnu dans les capacités")
CHECK_FALSE(decode_capabilities(nlohmann::json::array()).has_value());
}
TEST_CASE("Wire : playerInput des capacités, facultatif au décodage")
{
const nlohmann::json without = parse_text(
R"({"engineName": "godot", "engineVersion": "4.7", "buffers": [], "windowed": true, "headless": false})");
const auto decoded = decode_capabilities(without);
REQUIRE(decoded.has_value());
CHECK_FALSE(decoded->player_input);
EngineCapabilities caps;
caps.engine_name = "godot";
caps.player_input = true;
const auto round_trip = decode_capabilities(encode_capabilities(caps));
REQUIRE(round_trip.has_value());
CHECK(round_trip->player_input);
nlohmann::json bad = encode_capabilities(caps);
bad["playerInput"] = 1;
CHECK_FALSE(decode_capabilities(bad).has_value());
}
TEST_CASE("Wire : render/input fait l'aller-retour, bornes comprises")
{
PlayerInputMessage message;
message.input = PlayerInput{0xFFFFFFFFu, axis_max, -axis_max};
message.captured_at_us = 0x123456789ULL;
const nlohmann::json encoded = encode_player_input(message);
CHECK(encoded["capturedAtUs"] == "0000000123456789");
std::string error;
const auto decoded = decode_player_input(encoded, &error);
REQUIRE_MESSAGE(decoded.has_value(), error);
CHECK(decoded->input == message.input);
CHECK(decoded->captured_at_us == message.captured_at_us);
}
TEST_CASE("Wire : refus d'un render/input hors bornes ou mal typé")
{
const auto payload = [](nlohmann::json buttons, nlohmann::json x, nlohmann::json y) {
return nlohmann::json{{"buttons", std::move(buttons)}, {"moveX", std::move(x)}, {"moveY", std::move(y)},
{"capturedAtUs", "0"}};
};
std::string error;
CHECK(decode_player_input(payload(0, 0, 0)).has_value());
CHECK_FALSE(decode_player_input(payload(-1, 0, 0), &error).has_value());
CHECK(error.find("buttons") != std::string::npos);
CHECK_FALSE(decode_player_input(payload(0x100000000LL, 0, 0)).has_value());
CHECK_FALSE(decode_player_input(payload(std::numeric_limits<std::uint64_t>::max(), 0, 0)).has_value());
CHECK_FALSE(decode_player_input(payload(0, 32768, 0)).has_value());
CHECK_FALSE(decode_player_input(payload(0, 0, -32768)).has_value());
CHECK_FALSE(decode_player_input(payload(0, 0.5, 0)).has_value());
CHECK_FALSE(decode_player_input(payload("1", 0, 0)).has_value());
nlohmann::json no_time = payload(0, 0, 0);
no_time.erase("capturedAtUs");
CHECK_FALSE(decode_player_input(no_time).has_value());
no_time["capturedAtUs"] = "zz";
CHECK_FALSE(decode_player_input(no_time).has_value());
CHECK_FALSE(decode_player_input(nlohmann::json::array()).has_value());
}
TEST_CASE("Wire : un delta décodé s'applique à un SceneState sans erreur")
{
SceneDeltaMessage message;
+1 -1
View File
@@ -32,7 +32,7 @@ Phase 0 : fondations. Le chemin de bout en bout existe sous une forme minimale :
- le Core simule de façon déterministe un micro-mode 2D de démonstration (SIMULÉ), enregistre et rejoue les sessions, et sert le protocole Studio - Core 0.3 ([spécification](docs/specs/protocole-studio-core-0.2.md)) ;
- Studio (`sequa-studio` et l'interface web) démarre ou rejoint le Core, recharge le Core recompilé par rejeu et pilote le rendu depuis son panneau Rendu ;
- le Core lance `sequa-render`, qui affiche la scène simulée dans Godot (hébergé par LibGodot) ou l'écrit en images avec l'adaptateur de débogage ([protocole Core - rendu 0.1](docs/specs/protocole-core-rendu-0.1.md)).
- le Core lance `sequa-render`, qui affiche la scène simulée dans Godot (hébergé par LibGodot) ou l'écrit en images avec l'adaptateur de débogage ([protocole Core - rendu 0.2](docs/specs/protocole-core-rendu-0.1.md)) ; on joue dans la fenêtre Godot, les entrées traduites par l'adaptateur sont enregistrées par le Core ([ADR 0011](docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md)).
Pas encore de rendu neuronal ni de carte de confiance. Seul Windows a été construit et testé ([ADR 0004](docs/adr/0004-multiplateforme-et-verification-locale.md)).
+2 -1
View File
@@ -21,7 +21,8 @@ namespace sequa::core::server {
inline constexpr int envelope_version = 1;
inline constexpr std::string_view protocol_version = "0.3.0";
// Protocole Core - rendu (docs/specs/protocole-core-rendu-0.1.md), même enveloppe.
inline constexpr std::string_view render_protocol_version = "0.1.0";
// 0.2 : render/input, entrées du joueur captées par l'adaptateur (ADR 0011).
inline constexpr std::string_view render_protocol_version = "0.2.0";
inline constexpr std::string_view render_client_name = "sequa-render";
// Même règle que Studio : même majeure et, tant que la majeure vaut 0 (API instable
@@ -73,6 +73,7 @@ public:
void render_frames(ConnectionId id, const RenderFrames& frames) override;
void render_failed(ConnectionId id, const std::string& code, const std::string& message) override;
void render_closed(ConnectionId id) override;
void render_input(ConnectionId id, const three_d::PlayerInput& input) override;
// --- Rendu piloté par Studio (protocole 0.3, §9) ---
// Ces méthodes sont appelées depuis les fils des connexions Studio.
@@ -87,6 +88,7 @@ public:
void stop_render();
[[nodiscard]] RenderHub& render_hub() noexcept { return *render_; }
[[nodiscard]] LiveSession& live_session() noexcept { return session_; } // tests (advance sans fil)
private:
CoreRuntimeOptions options_;
@@ -2,6 +2,7 @@
#pragma once
#include "sequa/3d/capabilities.hpp"
#include "sequa/3d/input.hpp"
#include <cstddef>
#include <cstdint>
@@ -162,6 +163,9 @@ public:
virtual void render_frames(ConnectionId /*id*/, const RenderFrames& /*frames*/) {}
virtual void render_failed(ConnectionId /*id*/, const std::string& /*code*/, const std::string& /*message*/) {}
virtual void render_closed(ConnectionId /*id*/) {}
// Entrées du joueur captées par l'adaptateur (render/input, ADR 0011) : appliquées comme
// session/input. Ignorées si `id` n'est pas le canal de rendu courant.
virtual void render_input(ConnectionId /*id*/, const three_d::PlayerInput& /*input*/) {}
// --- Rendu piloté par Studio (protocole 0.3, §9). Implémentation par défaut pour
// ne pas casser les faux services existants. ---
+11 -1
View File
@@ -238,7 +238,8 @@ json engine_capabilities_json(const three_d::EngineCapabilities& caps)
{"version", caps.engine_version},
{"buffers", std::move(buffers)},
{"windowed", caps.windowed},
{"headless", caps.headless}};
{"headless", caps.headless},
{"playerInput", caps.player_input}};
}
// Convertit RenderFrames en JSON (measurements).
@@ -539,6 +540,15 @@ Reply ProtocolSession::on_text(std::string_view text)
services_.render_closed(connection_id_);
return reply;
}
if (type == "render/input") {
std::string why;
const auto input = three_d::wire::decode_player_input(payload, &why);
if (!input) {
return bad_payload(why);
}
services_.render_input(connection_id_, input->input);
return reply;
}
return error("unknown-type", "Type de message inconnu sur le canal de rendu : \"" + std::string(type) + "\".");
}
+15 -2
View File
@@ -51,6 +51,7 @@ std::optional<std::string> RenderHub::attach(ConnectionId id, PushFn push)
connection_ = id;
push_ = std::move(push);
ready_ = false;
input_held_ = false;
status_.connected = true;
status_.ready = false;
status_.capabilities.reset();
@@ -96,17 +97,29 @@ void RenderHub::closed(ConnectionId id)
}
}
void RenderHub::disconnected(ConnectionId id)
bool RenderHub::disconnected(ConnectionId id)
{
const std::lock_guard lock(mutex_);
if (connection_ != id) {
return;
return false;
}
connection_.reset();
push_ = nullptr;
ready_ = false;
status_.connected = false;
status_.ready = false;
return std::exchange(input_held_, false);
}
bool RenderHub::accept_input(ConnectionId id, const three_d::PlayerInput& input)
{
const std::lock_guard lock(mutex_);
if (connection_ != id) {
return false;
}
input_held_ = !input.is_neutral();
++status_.inputs_received;
return true;
}
void RenderHub::on_world(const sim::WorldState& world, std::uint64_t tick)
+9 -1
View File
@@ -25,6 +25,7 @@ public:
std::optional<RenderFrames> frames;
std::uint64_t world_version = 0; // dernière version envoyée
std::uint64_t deltas_sent = 0;
std::uint64_t inputs_received = 0; // render/input acceptés (ADR 0011)
std::string last_failure;
};
@@ -37,7 +38,13 @@ public:
void frames(ConnectionId id, const RenderFrames& frames);
void failed(ConnectionId id, const std::string& code, const std::string& message);
void closed(ConnectionId id);
void disconnected(ConnectionId id);
// Vrai si le rendu qui part maintenait une entrée non neutre : l'appelant la remet au
// neutre (ADR 0011 : pas de joueur qui court tout seul après la perte du rendu).
[[nodiscard]] bool disconnected(ConnectionId id);
// Vrai si `id` est le canal de rendu courant : l'appelant applique alors l'entrée à la
// session. Mémorise si l'entrée maintenue par le rendu est neutre.
[[nodiscard]] bool accept_input(ConnectionId id, const three_d::PlayerInput& input);
// Appelé sous le verrou de la session.
void on_world(const sim::WorldState& world, std::uint64_t tick);
@@ -54,6 +61,7 @@ private:
PushFn push_;
bool ready_ = false;
bool send_full_ = false;
bool input_held_ = false; // dernière entrée du rendu non neutre
std::uint64_t world_version_ = 0; // compteur du Core, jamais remis à zéro (spec §5)
Status status_;
};
+11 -1
View File
@@ -147,6 +147,13 @@ void CoreRuntime::render_closed(ConnectionId id)
render_->closed(id);
}
void CoreRuntime::render_input(ConnectionId id, const three_d::PlayerInput& input)
{
if (render_->accept_input(id, input)) {
(void)session_.set_input(input.buttons, input.move_x, input.move_y);
}
}
CoreRuntime::ExitReason CoreRuntime::exit_reason() const
{
const std::lock_guard lock(mutex_);
@@ -371,7 +378,10 @@ void CoreRuntime::on_client_connected()
void CoreRuntime::on_client_disconnected(ConnectionId id)
{
render_->disconnected(id); // avant que la socket ne disparaisse (fonction d'envoi oubliée)
// Avant que la socket ne disparaisse (fonction d'envoi oubliée).
if (render_->disconnected(id)) {
(void)session_.set_input(0, 0, 0); // entrée maintenue par le rendu perdu (ADR 0011)
}
const std::lock_guard lock(mutex_);
if (clients_ > 0) {
--clients_;
+1
View File
@@ -488,6 +488,7 @@ TEST_CASE("Protocole : render/status running avec tous les champs")
CHECK(payload["engine"]["buffers"] == json::array({"color"}));
CHECK(payload["engine"]["windowed"] == true);
CHECK(payload["engine"]["headless"] == false);
CHECK(payload["engine"]["playerInput"] == false);
CHECK(payload["width"] == 1280);
CHECK(payload["height"] == 720);
CHECK(payload["measurements"]["framesPerSecond"] == 180);
+145 -1
View File
@@ -7,6 +7,7 @@
#include "sequa/core/server/live_session.hpp"
#include "sequa/core/server/protocol.hpp"
#include "sequa/core/server/runtime.hpp"
#include "sequa/core/sim/input.hpp"
#include "sequa/3d/scene.hpp"
#include "sequa/3d/wire.hpp"
@@ -14,6 +15,7 @@
#include <doctest/doctest.h>
#include <nlohmann/json.hpp>
#include <cstdint>
#include <mutex>
#include <string>
#include <vector>
@@ -144,6 +146,148 @@ TEST_CASE("Canal de rendu : un studio ne peut pas devenir rendu sans fonction d'
CHECK(refused.close);
}
namespace {
std::string input_message(const nlohmann::json& payload)
{
return json{{"v", 1}, {"type", "render/input"}, {"payload", payload}}.dump();
}
std::string input_message(std::uint32_t buttons, int move_x, int move_y)
{
three_d::wire::PlayerInputMessage message;
message.input = three_d::PlayerInput{buttons, static_cast<std::int16_t>(move_x), static_cast<std::int16_t>(move_y)};
message.captured_at_us = 1;
return input_message(three_d::wire::encode_player_input(message));
}
// Session de reference sans rendu : memes entrees par session/input.
LiveSession reference_session()
{
LiveSession::Options options;
options.real_time = false;
return LiveSession(options);
}
} // namespace
TEST_CASE("Canal de rendu : render/input s'applique comme session/input et s'enregistre")
{
CoreRuntime runtime(offline());
Outbox outbox;
ProtocolSession render(runtime, 7, outbox.push());
REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome");
REQUIRE(render.on_text(ready_message()).messages.empty());
CHECK(render.on_text(input_message(sequa::core::sim::button::jump, 32767, 0)).messages.empty());
(void)runtime.live_session().advance(20);
CHECK(render.on_text(input_message(0, -12000, 0)).messages.empty());
(void)runtime.live_session().advance(20);
LiveSession reference = reference_session();
(void)reference.set_input(sequa::core::sim::button::jump, 32767, 0);
(void)reference.advance(20);
(void)reference.set_input(0, -12000, 0);
(void)reference.advance(20);
const SessionStateView got = runtime.session_status();
CHECK(got.tick == 40);
CHECK(got.input_count > 0);
CHECK(got.state_hash == reference.status().state_hash);
CHECK(runtime.render_hub().status().inputs_received == 2);
// Le rejeu de l'enregistrement exporte retrouve le meme etat.
const SessionExportData exported = runtime.session_export();
LiveSession replayed = reference_session();
const auto imported = replayed.import_session(exported.recording, false);
REQUIRE(imported.has_value());
CHECK(imported->state.state_hash == got.state_hash);
}
TEST_CASE("Canal de rendu : entree remise au neutre quand le rendu disparait")
{
CoreRuntime runtime(offline());
Outbox outbox;
runtime.on_client_connected();
ProtocolSession render(runtime, 7, outbox.push());
REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome");
REQUIRE(render.on_text(ready_message()).messages.empty());
REQUIRE(render.on_text(input_message(0, 32767, 0)).messages.empty()); // le joueur court
(void)runtime.live_session().advance(10);
runtime.on_client_disconnected(7);
(void)runtime.live_session().advance(30);
LiveSession reference = reference_session();
(void)reference.set_input(0, 32767, 0);
(void)reference.advance(10);
(void)reference.set_input(0, 0, 0);
(void)reference.advance(30);
CHECK(runtime.session_status().state_hash == reference.status().state_hash);
}
TEST_CASE("Canal de rendu : entree neutre du rendu, l'entree de Studio n'est pas ecrasee a la deconnexion")
{
CoreRuntime runtime(offline());
Outbox outbox;
runtime.on_client_connected();
ProtocolSession render(runtime, 7, outbox.push());
REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome");
REQUIRE(render.on_text(input_message(0, 0, 0)).messages.empty());
(void)runtime.session_input(0, 20000, 0); // Studio
runtime.on_client_disconnected(7);
(void)runtime.live_session().advance(10);
LiveSession reference = reference_session();
(void)reference.set_input(0, 20000, 0);
(void)reference.advance(10);
CHECK(runtime.session_status().state_hash == reference.status().state_hash);
}
TEST_CASE("Canal de rendu : render/input invalide refuse, entree inchangee")
{
CoreRuntime runtime(offline());
Outbox outbox;
ProtocolSession render(runtime, 7, outbox.push());
REQUIRE(json::parse(render.on_text(render_hello).messages.front())["type"] == "welcome");
const Reply refused = render.on_text(
input_message(json{{"buttons", 0}, {"moveX", 40000}, {"moveY", 0}, {"capturedAtUs", "0"}}));
REQUIRE(refused.messages.size() == 1);
const json error = json::parse(refused.messages.front());
CHECK(error["type"] == "error");
CHECK(error["payload"]["code"] == "invalid-payload");
CHECK_FALSE(refused.close);
CHECK(runtime.render_hub().status().inputs_received == 0);
(void)runtime.live_session().advance(5);
CHECK(runtime.session_status().input_count == 0);
}
TEST_CASE("Canal de rendu : render/input refuse sur une connexion Studio")
{
CoreRuntime runtime(offline());
ProtocolSession studio(runtime, 1);
REQUIRE(json::parse(studio.on_text(encode_hello(protocol_version, "sequa-studio")).messages.front())["type"] ==
"welcome");
const Reply refused = studio.on_text(input_message(0, 100, 0));
REQUIRE(refused.messages.size() == 1);
CHECK(json::parse(refused.messages.front())["type"] == "error");
CHECK(runtime.render_hub().status().inputs_received == 0);
}
TEST_CASE("Canal de rendu : entree d'un ancien canal ignoree")
{
RenderHub hub(sequa::core::sim::ArenaParams{});
Outbox outbox;
REQUIRE(hub.attach(1, outbox.push()).has_value());
CHECK(hub.accept_input(1, three_d::PlayerInput{0, 5, 0}));
CHECK(hub.disconnected(1)); // entree non neutre maintenue
REQUIRE(hub.attach(2, outbox.push()).has_value());
CHECK_FALSE(hub.accept_input(1, three_d::PlayerInput{0, 5, 0}));
CHECK_FALSE(hub.disconnected(1));
CHECK_FALSE(hub.disconnected(2)); // le nouveau canal n'a rien maintenu
}
TEST_CASE("Canal de rendu : un delta applicable par tick qui change la scene, versions croissantes")
{
LiveSession::Options options;
@@ -181,7 +325,7 @@ TEST_CASE("Canal de rendu : un delta applicable par tick qui change la scene, ve
}
CHECK(hub.status().world_version == mirror.world_version());
hub.disconnected(1);
CHECK_FALSE(hub.disconnected(1)); // aucune entree recue du rendu
(void)session.advance(10);
CHECK(outbox.take().empty()); // plus rien apres la deconnexion
}
@@ -0,0 +1,66 @@
# ADR 0011 : Entrées du joueur captées par l'adaptateur, traduction en actions dans l'adaptateur
- Statut : Proposée
- Date : 2026-11-07
- Décideur : Ange-Marie MAURIN - A2M Systèmes
- Références : docs/SEQUA_Engine_resume_complet_v2.md §4.2, §5.3, §7.0, §7.0.1, §7.2, §10.1, §14, §15 (décisions 1, 3, 6) ; ADR 0007, ADR 0008, ADR 0009 ; spécification [docs/specs/protocole-core-rendu-0.1.md](../specs/protocole-core-rendu-0.1.md) (version 0.2 du protocole)
## Contexte
Le Core sait recevoir des entrées (`session/input`, protocole Studio - Core 0.2), les applique au tick suivant et les enregistre : le rejeu est exact (§5.3). Mais aucune touche n'y est reliée, à part dans les tests. Pour éprouver la simulation dans la fenêtre du moteur (et bientôt la voie générative lente, étape 4 bis), il faut que le joueur puisse agir **depuis la fenêtre où il regarde le jeu**. L'ADR 0009 laisse ce chemin ouvert.
Contraintes :
- le moteur ne fait qu'afficher : il ne doit pas déplacer lui-même les objets, sinon ces mouvements échappent à l'enregistrement (décisions 1 et 3) ;
- SEQUA 3D est la seule porte vers le moteur, et rien n'y reprend un concept propre à un moteur (§7.0.1) ;
- l'auteur doit pouvoir écrire sa propre correspondance avancée (manette, combinaisons, double appui, zones mortes) sans recompiler SEQUA ;
- la simulation ne reçoit que des valeurs entières (§10.1).
## Options envisagées
### Où traduire les touches en actions
1. **Dans le Core** : le moteur envoie les touches brutes (codes de touches, axes de manette), le Core les traduit. Une seule table pour tous les moteurs, mais il faut définir un vocabulaire universel de touches et d'appareils, et l'auteur ne peut pas réutiliser les outils de son moteur.
2. **Dans l'adaptateur** : l'adaptateur traduit les appareils en **actions SEQUA** (boutons et axes quantifiés) et ne transmet que ces actions. Chaque moteur offre déjà un système de correspondance (InputMap dans Godot, Enhanced Input dans Unreal), que l'auteur sait utiliser. Ce qui part vers le Core est le même pour tous les moteurs.
### Ce qui est enregistré
Dans les deux cas, le Core enregistre ce qu'il reçoit au tick où il l'applique. Avec l'option 2, ce sont les actions : le rejeu ne dépend pas de la correspondance, qui peut changer sans invalider les enregistrements.
## Décision
**Option 2 : l'adaptateur capte les appareils et les traduit en actions ; le Core ne reçoit et n'enregistre que des actions.**
- **Contrat SEQUA 3D** : type `three_d::PlayerInput` `{ buttons: uint32, move_x: int16, move_y: int16 }` (axes dans [-32767, 32767], même forme que `sim::InputFrame`), méthode `I3DEngineAdapter::poll_input()` appelée après chaque image, capacité `EngineCapabilities::player_input`. La signification des bits appartient au mode de jeu du Core (aujourd'hui `sim::button::jump`, bit 0). Version d'interface des adaptateurs : 2.
- **Protocole Core - rendu 0.2** : message `render/input` du rendu vers le Core, envoyé **seulement quand les actions changent**. Le Core l'applique comme `session/input` (prochain tick exécuté, entrée maintenue, la dernière reçue gagne, quelle que soit sa source). À la déconnexion du rendu, une entrée encore maintenue par lui est remise au neutre (pas de joueur qui court tout seul après un plantage).
- **Focus** : un adaptateur ne capte que lorsque sa fenêtre a le focus ; sans focus, il renvoie l'entrée neutre.
- **Adaptateur Godot, deux niveaux de correspondance** :
1. *Par défaut* : actions de l'InputMap du projet Godot (`project.godot`, section `[input]`), modifiables dans l'éditeur Godot ou à la main : `sequa_move_left`, `sequa_move_right`, `sequa_move_down`, `sequa_move_up` (axes, zones mortes de Godot), `sequa_jump` (bit 0) et `sequa_button_1` à `sequa_button_31` (bits suivants, facultatifs). Le projet fourni les relie au clavier (flèches, ZQSD en position physique, espace) et à la manette.
2. *Avancé* : si la scène principale contient un nœud `SequaInput` doté d'une méthode `sequa_player_input()`, l'adaptateur l'appelle à chaque image à la place du niveau 1. Elle renvoie `{ "buttons": int, "move_x": float, "move_y": float }` (axes dans [-1, 1]) ; l'auteur y écrit ce qu'il veut en GDScript (combinaisons, double appui, appareils particuliers). Un résultat invalide donne l'entrée neutre et un message dans le journal du rendu.
Un autre projet Godot se désigne déjà par `--adapter-options {"project": ...}`.
- Ce script ne produit **que des actions** : il ne touche pas aux objets de la scène SEQUA (synchronisation à sens unique, §7.0).
- **Adaptateur de débogage** : entrées scriptées par numéro d'image (`--adapter-options {"input": [...]}`), pour tester le chemin complet sans moteur ni clavier.
## Conséquences
### Positives
- On joue dans la fenêtre du jeu ; Studio n'a pas besoin du focus.
- L'auteur règle sa correspondance avec les outils de son moteur, et peut aller jusqu'au script, sans recompiler SEQUA.
- Le Core reste indépendant du moteur et des appareils ; les enregistrements restent valables si la correspondance change.
- Unreal se branchera de la même façon (Enhanced Input traduit en `PlayerInput`).
### Négatives et coûts
- Chaque adaptateur réécrit sa traduction ; une correspondance écrite pour Godot ne sert pas telle quelle à Unreal.
- La correspondance n'est pas enregistrée : on rejoue les actions, pas les touches. Pour analyser le ressenti d'un appareil (§4.2), il faudra un enregistrement à part des entrées brutes.
- Un script de correspondance mal écrit peut ralentir l'image : il tourne dans la boucle du moteur.
- Deux sources d'entrées (Studio et rendu) se partagent l'entrée maintenue : la dernière gagne, sans arbitrage.
- Changement d'interface des adaptateurs (version 2) et du protocole Core - rendu (0.2) : rendu, Core et adaptateurs doivent être reconstruits ensemble (c'est déjà le cas avec le build unifié, ADR 0010).
### Questions ouvertes
- Latence entre la touche et l'affichage (§14) : `render/input` porte l'instant de capture ; la mesure de bout en bout viendra avec la lecture des tampons (étape 5).
- Plusieurs joueurs et plusieurs appareils : un seul joueur pour l'instant.
- Arbitrage entre Studio et le rendu quand les deux envoient des entrées : à décider si cela gêne.
- Souris et visée (axes relatifs) : à ajouter quand un mode de jeu en aura besoin.
+1
View File
@@ -22,6 +22,7 @@ Une ADR (*Architecture Decision Record*) consigne **une** décision structurante
| [0008](0008-processus-de-rendu-et-bibliotheques-chargees.md) | Processus de rendu séparé du Core, moteur et inférence en bibliothèques chargées | Acceptée |
| [0009](0009-processus-de-rendu-lance-par-le-core.md) | Processus de rendu lancé par le Core, protocole Core ↔ rendu, adaptateurs en bibliothèques chargées | Acceptée |
| [0010](0010-arborescence-des-binaires-et-build-unifie.md) | Arborescence des binaires (développement et installation), build unifié | Acceptée |
| [0011](0011-entrees-du-joueur-captees-par-l-adaptateur.md) | Entrées du joueur captées par l'adaptateur, traduction en actions dans l'adaptateur | Proposée |
## Décisions ouvertes (docs, §17)
+18 -7
View File
@@ -1,6 +1,7 @@
# Spécification : protocole Core - rendu 0.1 et `sequa-render`
# Spécification : protocole Core - rendu 0.2 et `sequa-render`
- Statut : **brouillon** (étape 4). Décisions : [ADR 0009](../adr/0009-processus-de-rendu-lance-par-le-core.md).
- Statut : **brouillon** (étape 4). Décisions : [ADR 0009](../adr/0009-processus-de-rendu-lance-par-le-core.md) ; entrées du joueur (0.2) : [ADR 0011](../adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md).
- Historique : 0.1 (scène, mesures) ; **0.2** : message `render/input`, capacité `playerInput`, interface des adaptateurs en version 2. Le fichier garde son nom d'origine.
- Le format reste du **JSON provisoire** (ADR 0005) en attendant l'ADR du schéma. Enveloppe, entiers 64 bits en hexadécimal et règles d'erreur : comme le [protocole Studio - Core 0.2](protocole-studio-core-0.2.md) §2 et §3.
- Toute ambiguïté se règle en modifiant CE document.
@@ -44,14 +45,14 @@ sequa::three_d::I3DEngineAdapter* sequa_create_engine_adapter(std::uint32_t inte
void sequa_destroy_engine_adapter(sequa::three_d::I3DEngineAdapter* adapter);
```
`interface_version` = `sequa::three_d::adapter_interface_version` (entier, 1 au départ), incrémenté à chaque changement de `I3DEngineAdapter` ou des types du contrat. Le rendu détruit l'adaptateur, **décharge sa bibliothèque**, puis seulement ensuite tout ce que l'adaptateur aurait chargé lui-même (le moteur, déchargé par l'adaptateur à sa destruction).
`interface_version` = `sequa::three_d::adapter_interface_version` (entier, 1 au départ, 2 depuis `poll_input`), incrémenté à chaque changement de `I3DEngineAdapter` ou des types du contrat. Le rendu détruit l'adaptateur, **décharge sa bibliothèque**, puis seulement ensuite tout ce que l'adaptateur aurait chargé lui-même (le moteur, déchargé par l'adaptateur à sa destruction).
## 4. Poignée de main
1. Connexion avec le jeton (protocole 0.2 §3).
2. Rendu → Core : `hello` `{ protocolVersion: "0.1.0", client: "sequa-render" }`. Le Core reconnaît le canal de rendu à `client` et applique la version de CE protocole (compatibilité : même majeure et, en 0.x, même mineure).
2. Rendu → Core : `hello` `{ protocolVersion: "0.2.0", client: "sequa-render" }`. Le Core reconnaît le canal de rendu à `client` et applique la version de CE protocole (compatibilité : même majeure et, en 0.x, même mineure).
3. Core → rendu : `welcome` `{ protocolVersion, coreVersion, instanceId, renderId }` (`renderId` : identifiant de ce lancement, 32 hex).
4. Rendu → Core : `render/ready` `{ capabilities: { engineName, engineVersion, buffers: [noms §5.6], windowed, headless }, width, height }` après `start()` réussi de l'adaptateur. En cas d'échec : `render/failed` `{ code, message }` puis fermeture et code de sortie 1.
4. Rendu → Core : `render/ready` `{ capabilities: { engineName, engineVersion, buffers: [noms §5.6], windowed, headless, playerInput }, width, height }` (`playerInput` : le moteur capte les entrées du joueur, facultatif, faux par défaut) après `start()` réussi de l'adaptateur. En cas d'échec : `render/failed` `{ code, message }` puis fermeture et code de sortie 1.
5. Core → rendu : `scene/delta` avec `full: true` (scène complète), puis les deltas suivants.
Un seul canal de rendu à la fois : une deuxième connexion `sequa-render` reçoit `error` `{ code: "render-exists" }` et est fermée.
@@ -88,9 +89,19 @@ Commandes (champ `op`) ; identifiants d'objets en hexadécimal, nombres en JSON
| `render/frames` | `{ frames: int, lastWorldVersion: hex, cpuMsMedian, cpuMsP95, latencyUsMedian, latencyUsP95, applyUsMedian, applyUsP95 }` : envoyé toutes les secondes. `latencyUs` = réception du delta − `sentAtUs` (transport seul : c'est le critère de l'ADR 0009) ; `applyUs` = application du delta − `sentAtUs` (attente de la prochaine image comprise) |
| `render/failed` | `{ code, message }` : l'adaptateur a échoué ; fermeture, code de sortie 1 |
| `render/closed` | `{}` : l'utilisateur a fermé la fenêtre ; fermeture, code de sortie 4 |
| `render/input` | `{ buttons: int [0, 2^32-1], moveX: int, moveY: int ([-32767, 32767]), capturedAtUs: hex }` : entrées du joueur déjà traduites en actions par l'adaptateur (§5.1) |
Une erreur d'application d'un delta (version qui recule, id inconnu) est une erreur de protocole : `render/failed` `{ code: "invalid-delta" }`.
### 5.1 Entrées du joueur (0.2, ADR 0011)
- Après chaque image, si l'adaptateur déclare `playerInput`, le rendu appelle `poll_input()` et envoie `render/input` **seulement si les actions ont changé** depuis le dernier envoi (départ : entrée neutre, rien n'est envoyé tant que le joueur ne fait rien).
- La traduction des appareils (clavier, manette…) en actions appartient à l'adaptateur. Hors focus, l'adaptateur renvoie l'entrée neutre.
- Le Core applique `render/input` comme `session/input` (protocole Studio - Core §4) : prochain tick exécuté, entrée maintenue, la dernière reçue gagne, qu'elle vienne de Studio ou du rendu. Elle est enregistrée : le rejeu est exact.
- Payload invalide : `error` `{ code: "invalid-payload" }`, entrée inchangée, connexion conservée. `render/input` reçu sur une connexion Studio : `error` `unknown-type`.
- Déconnexion du rendu alors que sa dernière entrée n'était pas neutre : le Core remet l'entrée au neutre.
- `capturedAtUs` : horloge monotone du rendu à la lecture (comme `sentAtUs`), pour la mesure future de la latence entre la touche et l'affichage.
## 6. Ajouts au protocole Studio - Core (0.3)
| Requête | Payload envoyé | Réponse |
@@ -114,6 +125,6 @@ Une erreur d'application d'un delta (version qui recule, id inconnu) est une err
- Le rendu reçoit sur le fil du client, empile, et applique **tous** les deltas reçus avant chaque image, dans l'ordre, puis appelle `render_frame()`. Pas d'interpolation en 0.1 (ADR 0009).
- Si le rendu prend du retard, les deltas s'accumulent mais aucun n'est sauté : la scène reste exacte.
## 9. Hors 0.1
## 9. Hors 0.2
Tampons de rendu (étape 5), entrées du joueur captées par la fenêtre du moteur, mesures de VRAM, plusieurs rendus simultanés, interpolation.
Tampons de rendu (étape 5), mesures de VRAM, plusieurs rendus simultanés, interpolation, plusieurs joueurs, souris et visée.
+1 -1
View File
@@ -157,7 +157,7 @@ Bornes : `width` et `height` dans [64, 7680] (défaut 1280 × 720), `fps` dans [
"adapter": "godot", // absent si stopped
"pid": 1234, // absent si aucun processus
"restarts": 0,
"engine": { "name": "godot", "version": "4.7.2-stable", "buffers": ["color", "depth"], "windowed": true, "headless": false },
"engine": { "name": "godot", "version": "4.7.2-stable", "buffers": ["color", "depth"], "windowed": true, "headless": false, "playerInput": true },
"width": 1280, "height": 720, // absents tant que le rendu n'est pas prêt
"measurements": { "framesPerSecond": 180, "cpuMsP95": 6.1, "latencyUsP95": 340, "applyUsP95": 5900 },
"worldVersion": "00000000000001d5", // dernière version de scène envoyée
+14
View File
@@ -22,6 +22,20 @@ adapter.render_frame(request); // dessine et écrit une image ; adapter.last_ima
- `sequa::debug::PpmSequenceSink` : une image par fichier `frame_NNNNNN.ppm` (binaire P6).
- `sequa::debug::FrameSink` : interface pour d'autres destinations.
## Entrées scriptées
Sans clavier ni fenêtre, l'adaptateur « joue » un script d'entrées par numéro d'image
([ADR 0011](../../../docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md)) : le chemin
complet des entrées (adaptateur → rendu → Core) se teste sans moteur. Réglage de
`--adapter-options` (ou `--render-options` du Core) :
```json
{ "input": [{ "frame": 20, "moveX": 32767 }, { "frame": 60, "buttons": 1, "moveX": 32767 }, { "frame": 100 }] }
```
À partir de l'image `frame`, l'entrée reste active jusqu'à la suivante ; champs absents = 0
(`{ "frame": 100 }` relâche tout). Sans script, l'adaptateur ne déclare pas de capture d'entrées.
## ffmpeg facultatif
`sequa::debug::FfmpegSink` lance `ffmpeg` en sous-processus (`_popen`/`popen`) pour écrire
@@ -5,20 +5,34 @@
#include "sequa/debug/frame_sink.hpp"
#include "sequa/debug/raster.hpp"
#include <cstdint>
#include <memory>
#include <vector>
namespace sequa::debug {
// Entrée scriptée : à partir de l'image `frame` (FrameRequest::frame_index), l'adaptateur
// renvoie `input` jusqu'à l'entrée suivante (ADR 0011).
struct ScriptedInput {
std::uint64_t frame = 0;
three_d::PlayerInput input;
};
// Adaptateur SEQUA 3D « de débogage » : outil de débogage et de mode sans interface
// (docs §7.1 : deuxième adaptateur, test d'isolation léger), pas un rendu produit.
//
// Il rastérise la scène en vue de côté 2D (voire raster.hpp) sans GPU ni moteur, et écrit
// l'image dans le FrameSink fourni (images PPM ou vidéo ffmpeg externe ; un sink nul
// n'écrit rien, l'image reste consultable via last_image()).
//
// Sans clavier ni fenêtre, il « joue » un script d'entrées : le chemin complet des entrées
// (adaptateur → rendu → Core) se teste sans moteur. Sans script, il ne déclare pas de
// capture d'entrées.
class DebugEngineAdapter final : public three_d::I3DEngineAdapter {
public:
// sink peut être nul : aucune écriture ; view définit la vue de côté.
DebugEngineAdapter(std::unique_ptr<FrameSink> sink, ViewParams view = {});
// sink peut être nul : aucune écriture ; view définit la vue de côté ; script est trié
// par image (ordre stable pour une même image : la dernière gagne).
DebugEngineAdapter(std::unique_ptr<FrameSink> sink, ViewParams view = {}, std::vector<ScriptedInput> script = {});
[[nodiscard]] three_d::EngineCapabilities capabilities() const override;
@@ -28,6 +42,8 @@ public:
[[nodiscard]] std::optional<three_d::Error> apply(const three_d::SceneDelta& delta) override;
[[nodiscard]] three_d::FrameOutcome render_frame(const three_d::FrameRequest& request) override;
// Entrée du script pour la dernière image rendue (neutre avant la première entrée).
[[nodiscard]] std::optional<three_d::PlayerInput> poll_input() override;
// Dernière image rastérisée, à lire pour les tests et le journal.
[[nodiscard]] const Image& last_image() const noexcept { return last_image_; }
@@ -35,6 +51,8 @@ public:
private:
std::unique_ptr<FrameSink> sink_;
ViewParams view_;
std::vector<ScriptedInput> script_;
std::optional<std::uint64_t> last_frame_; // dernière image rendue depuis start()
three_d::SceneState scene_;
bool running_ = false;
std::uint32_t width_ = 0; // taille mémorisée par start()
+26 -2
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/debug/debug_adapter.hpp"
#include <algorithm>
#include <chrono>
#include <utility>
@@ -10,9 +11,12 @@ using three_d::BufferSet;
using three_d::Error;
using three_d::FrameBuffer;
DebugEngineAdapter::DebugEngineAdapter(std::unique_ptr<FrameSink> sink, ViewParams view)
: sink_(std::move(sink)), view_(view)
DebugEngineAdapter::DebugEngineAdapter(std::unique_ptr<FrameSink> sink, ViewParams view,
std::vector<ScriptedInput> script)
: sink_(std::move(sink)), view_(view), script_(std::move(script))
{
std::stable_sort(script_.begin(), script_.end(),
[](const ScriptedInput& a, const ScriptedInput& b) { return a.frame < b.frame; });
}
three_d::EngineCapabilities DebugEngineAdapter::capabilities() const
@@ -25,9 +29,27 @@ three_d::EngineCapabilities DebugEngineAdapter::capabilities() const
capabilities.windowed = false;
capabilities.headless = true;
// max_width/max_height laissés à 0 : aucune limite de taille.
capabilities.player_input = !script_.empty();
return capabilities;
}
std::optional<three_d::PlayerInput> DebugEngineAdapter::poll_input()
{
if (!running_ || script_.empty()) {
return std::nullopt;
}
three_d::PlayerInput current;
if (last_frame_) {
for (const ScriptedInput& entry : script_) {
if (entry.frame > *last_frame_) {
break;
}
current = entry.input;
}
}
return current;
}
std::optional<Error> DebugEngineAdapter::start(const three_d::EngineConfig& config)
{
if (running_) {
@@ -44,6 +66,7 @@ std::optional<Error> DebugEngineAdapter::start(const three_d::EngineConfig& conf
width_ = config.width;
height_ = config.height;
scene_.clear(); // après start(), la scène du moteur est vide (l'appelant pousse un snapshot)
last_frame_.reset();
running_ = true;
return std::nullopt;
}
@@ -87,6 +110,7 @@ three_d::FrameOutcome DebugEngineAdapter::render_frame(const three_d::FrameReque
return *std::move(error);
}
}
last_frame_ = request.frame_index;
three_d::FrameResult result;
result.frame_index = request.frame_index;
result.world_version = scene_.world_version();
+53 -3
View File
@@ -2,19 +2,25 @@
// Point d'entrée de la bibliothèque d'adaptateur de débogage (ADR 0009).
//
// Réglages (--adapter-options de sequa-render, tous facultatifs) :
// { "output": "<dossier ou fichier vidéo>", "fps": 10, "ffmpeg": "ffmpeg", "viewHeightM": 12 }
// { "output": "<dossier ou fichier vidéo>", "fps": 10, "ffmpeg": "ffmpeg", "viewHeightM": 12,
// "input": [{ "frame": 0, "buttons": 0, "moveX": 32767, "moveY": 0 }, ...] }
// Sortie .mp4, .mkv, .webm ou .avi : vidéo par ffmpeg (outil externe) ; autre chemin : dossier
// d'images PPM ; pas de sortie : rien n'est écrit.
// d'images PPM ; pas de sortie : rien n'est écrit. "input" : entrées scriptées par numéro
// d'image (ADR 0011), champs autres que "frame" à 0 par défaut.
#include "sequa/3d/adapter_library.hpp"
#include "sequa/debug/debug_adapter.hpp"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <filesystem>
#include <limits>
#include <memory>
#include <new>
#include <string>
#include <string_view>
#include <vector>
namespace {
@@ -56,6 +62,50 @@ SEQUA_ADAPTER_EXPORT sequa::three_d::I3DEngineAdapter* sequa_create_engine_adapt
view.view_height_m = static_cast<float>(it->get<double>());
}
// Entrées scriptées (ADR 0011) : [{ "frame": n, "buttons": b, "moveX": x, "moveY": y }, ...].
std::vector<sequa::debug::ScriptedInput> script;
if (const auto it = options.find("input"); it != options.end()) {
if (!it->is_array()) {
write_adapter_error(error, error_size, "adaptateur debug : \"input\" doit être un tableau");
return nullptr;
}
const auto integer_in = [](const nlohmann::json& entry, const char* key, std::int64_t low, std::int64_t high,
std::int64_t& out) {
if (!entry.contains(key)) {
out = 0;
return std::string_view(key) != "frame"; // seule l'image est obligatoire
}
const nlohmann::json& value = entry[key];
if (!value.is_number_integer()) {
return false;
}
if (value.is_number_unsigned() && value.get<std::uint64_t>() > static_cast<std::uint64_t>(high)) {
return false;
}
out = value.get<std::int64_t>();
return out >= low && out <= high;
};
for (const nlohmann::json& entry : *it) {
std::int64_t frame = 0;
std::int64_t buttons = 0;
std::int64_t move_x = 0;
std::int64_t move_y = 0;
const std::int64_t axis = sequa::three_d::axis_max;
if (!entry.is_object() || !integer_in(entry, "frame", 0, std::numeric_limits<std::int64_t>::max(), frame) ||
!integer_in(entry, "buttons", 0, 0xFFFFFFFFLL, buttons) ||
!integer_in(entry, "moveX", -axis, axis, move_x) || !integer_in(entry, "moveY", -axis, axis, move_y)) {
write_adapter_error(error, error_size,
"adaptateur debug : entrée de \"input\" invalide (frame >= 0 obligatoire, "
"buttons sur 32 bits, moveX/moveY dans [-32767, 32767])");
return nullptr;
}
script.push_back({static_cast<std::uint64_t>(frame),
sequa::three_d::PlayerInput{static_cast<std::uint32_t>(buttons),
static_cast<std::int16_t>(move_x),
static_cast<std::int16_t>(move_y)}});
}
}
std::unique_ptr<sequa::debug::FrameSink> sink;
const std::string output_text = string_option("output", "");
const std::filesystem::path output(std::u8string(output_text.begin(), output_text.end())); // UTF-8
@@ -66,7 +116,7 @@ SEQUA_ADAPTER_EXPORT sequa::three_d::I3DEngineAdapter* sequa_create_engine_adapt
sink = std::make_unique<sequa::debug::PpmSequenceSink>(output);
}
}
return new (std::nothrow) sequa::debug::DebugEngineAdapter(std::move(sink), view);
return new (std::nothrow) sequa::debug::DebugEngineAdapter(std::move(sink), view, std::move(script));
}
SEQUA_ADAPTER_EXPORT void sequa_destroy_engine_adapter(sequa::three_d::I3DEngineAdapter* adapter)
@@ -180,3 +180,37 @@ TEST_CASE("Debug : stop puis redémarrage, la scène est vidée")
REQUIRE(std::holds_alternative<three_d::FrameResult>(outcome));
CHECK(std::get<three_d::FrameResult>(outcome).world_version == 5);
}
TEST_CASE("Debug : entrees scriptees par numero d'image")
{
debug::DebugEngineAdapter silent(nullptr);
CHECK_FALSE(silent.capabilities().player_input);
REQUIRE_FALSE(silent.start(headless_config()));
CHECK_FALSE(silent.poll_input().has_value());
// Desordre volontaire : le constructeur trie par image.
debug::DebugEngineAdapter adapter(nullptr, {},
{{10, three_d::PlayerInput{0, -100, 0}},
{2, three_d::PlayerInput{1, 32767, 0}}});
CHECK(adapter.capabilities().player_input);
CHECK_FALSE(adapter.poll_input().has_value()); // arrete
REQUIRE_FALSE(adapter.start(headless_config()));
REQUIRE(adapter.poll_input().has_value());
CHECK(adapter.poll_input()->is_neutral()); // aucune image rendue
const auto input_after = [&](std::uint64_t frame) {
three_d::FrameRequest request;
request.frame_index = frame;
REQUIRE(std::holds_alternative<three_d::FrameResult>(adapter.render_frame(request)));
return *adapter.poll_input();
};
CHECK(input_after(0).is_neutral());
CHECK(input_after(2) == three_d::PlayerInput{1, 32767, 0});
CHECK(input_after(9) == three_d::PlayerInput{1, 32767, 0});
CHECK(input_after(10) == three_d::PlayerInput{0, -100, 0});
CHECK(input_after(500) == three_d::PlayerInput{0, -100, 0});
adapter.stop();
REQUIRE_FALSE(adapter.start(headless_config()));
CHECK(adapter.poll_input()->is_neutral()); // redemarrage : on repart du debut
}
+4 -3
View File
@@ -4,7 +4,7 @@
# LibGodot (scripts/build-godot.ps1) ; avec SEQUA_BUILD_GODOT_RUNTIME, godot-cpp
# (../vendor/godot-cpp) est compilé ici pour piloter Godot depuis l'adaptateur (ADR 0007).
add_library(sequa_godot_adapter src/godot_adapter.cpp src/conversions.cpp)
add_library(sequa_godot_adapter src/godot_adapter.cpp src/conversions.cpp src/input_mapping.cpp)
add_library(sequa::godot_adapter ALIAS sequa_godot_adapter)
# Le nom « sequa_godot_adapter » revient à la bibliothèque chargée (et à son .lib d'import).
set_target_properties(sequa_godot_adapter PROPERTIES OUTPUT_NAME sequa_godot_adapter_static)
@@ -55,7 +55,7 @@ if(SEQUA_BUILD_GODOT_RUNTIME)
# godot-cpp écrirait sinon ses .pdb dans <source>/bin.
set_target_properties(godot-cpp PROPERTIES PDB_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/godot-cpp/bin")
target_sources(sequa_godot_adapter PRIVATE src/godot_host.cpp src/godot_scene.cpp)
target_sources(sequa_godot_adapter PRIVATE src/godot_host.cpp src/godot_scene.cpp src/godot_input.cpp)
target_link_libraries(sequa_godot_adapter PRIVATE godot::cpp sequa::layout)
target_compile_definitions(sequa_godot_adapter PRIVATE SEQUA_GODOT_RUNTIME=1)
@@ -92,7 +92,8 @@ else()
endif()
if(SEQUA_BUILD_TESTS)
add_executable(sequa_godot_adapter_tests tests/test_godot_adapter.cpp tests/test_conversions.cpp)
add_executable(sequa_godot_adapter_tests tests/test_godot_adapter.cpp tests/test_conversions.cpp
tests/test_input_mapping.cpp)
target_link_libraries(sequa_godot_adapter_tests PRIVATE sequa::godot_adapter doctest::doctest)
sequa_target_defaults(sequa_godot_adapter_tests)
doctest_discover_tests(sequa_godot_adapter_tests)
+22
View File
@@ -17,7 +17,29 @@ Implémentation de `sequa::three_d::I3DEngineAdapter` (voir [3d/](../../../3d))
|---|---|
| `src/godot_host.*` | LibGodot : chargement de la DLL, instance, godot-cpp en extension hôte, boucle |
| `src/godot_scene.*` | Nœuds Godot qui reflètent la scène SEQUA (deltas déjà validés par `SceneState`) |
| `src/godot_input.*` | Lecture des entrées du joueur dans Godot (InputMap ou script `SequaInput`) |
| `include/sequa/godot/conversions.hpp` | Conversions pures (rotation, couleur sRGB, formes), testées sans moteur |
| `include/sequa/godot/input_mapping.hpp` | Noms des actions et traduction en `PlayerInput`, testées sans moteur |
| `examples/sequa_input.gd` | Exemple de correspondance avancée (double appui pour courir) |
### Entrées du joueur ([ADR 0011](../../../docs/adr/0011-entrees-du-joueur-captees-par-l-adaptateur.md))
L'adaptateur lit le clavier et la manette dans la fenêtre Godot, les traduit en **actions SEQUA** (`buttons` sur 32 bits, axes `move_x` et `move_y`) et `sequa-render` les envoie au Core, qui les applique et les enregistre (rejeu exact). Godot ne déplace rien lui-même. Sans focus, l'entrée est neutre.
**Niveau 1, par défaut : l'InputMap du projet Godot** (`project/project.godot`, section `[input]`), modifiable dans l'éditeur Godot (Projet > Paramètres du projet > Contrôles) ou à la main :
| Action Godot | Action SEQUA | Projet fourni |
|---|---|---|
| `sequa_move_left` / `sequa_move_right` | `move_x` (droite − gauche) | Q / D (AZERTY, position physique A / D), flèches, stick gauche, croix |
| `sequa_move_down` / `sequa_move_up` | `move_y` (haut − bas) | S / Z, flèches, stick gauche, croix |
| `sequa_jump` | bit 0 (saut) | Espace, bouton A (Croix) |
| `sequa_button_1` … `sequa_button_31` | bits 1 à 31 | non définies (facultatives) |
Une action absente compte pour zéro. Les zones mortes et l'analogique sont ceux de Godot.
**Niveau 2, avancé : un script.** Ajouter à la racine de la scène principale (`main.tscn`) un nœud nommé `SequaInput` avec un script qui définit `sequa_player_input()` et renvoie `{ "buttons": int, "move_x": float, "move_y": float }` (axes dans [-1, 1], clés absentes = 0). L'adaptateur l'appelle à chaque image à la place du niveau 1 et l'annonce dans le journal du rendu ; un résultat invalide donne l'entrée neutre et un message (une fois). Exemple : [`examples/sequa_input.gd`](examples/sequa_input.gd). Le script ne doit produire que des actions, jamais toucher aux objets de `SequaScene`.
Pour utiliser son propre projet Godot sans toucher au projet fourni : `--adapter-options {"project": "C:/chemin/vers/projet"}` (le projet doit contenir le nœud `SequaScene`).
### Recherche de la bibliothèque et du projet Godot (ADR 0010)
@@ -0,0 +1,37 @@
# SPDX-License-Identifier: MPL-2.0
#
# Exemple de correspondance avancée des entrées (ADR 0011, niveau 2). À attacher à un nœud
# nommé « SequaInput », enfant direct de la racine de la scène principale du projet Godot
# (main.tscn). Dès qu'il existe, l'adaptateur appelle sequa_player_input() à chaque image à
# la place de l'InputMap.
#
# Ce script ne produit QUE des actions : il ne touche pas aux objets de la scène SEQUA (le
# Core reste la source de vérité et enregistre ces actions pour le rejeu).
#
# Exemple : course en double appui sur une direction, saut tant que la touche est tenue.
extends Node
const DOUBLE_TAP_MS := 250
const WALK := 0.5
var _last_tap_ms := { "left": -100000, "right": -100000 }
var _running := false
func _process(_delta: float) -> void:
for side in ["left", "right"]:
if Input.is_action_just_pressed("sequa_move_" + side):
var now := Time.get_ticks_msec()
_running = now - _last_tap_ms[side] < DOUBLE_TAP_MS
_last_tap_ms[side] = now
func sequa_player_input() -> Dictionary:
var axis := Input.get_axis("sequa_move_left", "sequa_move_right")
if axis == 0.0:
_running = false
var speed := 1.0 if _running else WALK
var buttons := 1 if Input.is_action_pressed("sequa_jump") else 0 # bit 0 : saut
return {
"buttons": buttons,
"move_x": axis * speed,
"move_y": Input.get_axis("sequa_move_down", "sequa_move_up"),
}
@@ -49,6 +49,8 @@ public:
[[nodiscard]] std::optional<three_d::Error> apply(const three_d::SceneDelta& delta) override;
[[nodiscard]] three_d::FrameOutcome render_frame(const three_d::FrameRequest& request) override;
// Correspondance des entrées : sequa/godot/input_mapping.hpp (ADR 0011).
[[nodiscard]] std::optional<three_d::PlayerInput> poll_input() override;
private:
struct Runtime; // moteur hébergé et nœuds de la scène (vide sans SEQUA_GODOT_RUNTIME)
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include "sequa/3d/input.hpp"
#include <cstdint>
#include <optional>
#include <string>
namespace sequa::godot {
// Correspondance entre les appareils et les actions SEQUA, propre à l'adaptateur Godot
// (ADR 0011). Deux niveaux :
//
// 1. Par défaut : actions de l'InputMap du projet Godot (project.godot, section [input]),
// réglables dans l'éditeur Godot ou à la main. Une action absente compte pour zéro.
// 2. Avancé : un nœud `SequaInput` à la racine de la scène principale, doté d'une méthode
// `sequa_player_input()` qui renvoie { "buttons": int, "move_x": float, "move_y": float }
// (axes dans [-1, 1], clés absentes = 0). Elle remplace le niveau 1.
//
// Seule la traduction en PlayerInput est ici ; la lecture de Godot est dans godot_input.cpp.
inline constexpr const char* action_move_left = "sequa_move_left";
inline constexpr const char* action_move_right = "sequa_move_right";
inline constexpr const char* action_move_down = "sequa_move_down";
inline constexpr const char* action_move_up = "sequa_move_up";
inline constexpr const char* action_jump = "sequa_jump"; // bit 0
inline constexpr std::uint32_t action_button_count = 32; // sequa_button_1 … sequa_button_31 : bits 1 à 31
inline constexpr const char* input_node_name = "SequaInput";
inline constexpr const char* input_method_name = "sequa_player_input";
// Nom de l'action Godot du bit `bit` : sequa_jump pour 0, sequa_button_<bit> ensuite.
[[nodiscard]] std::string button_action_name(std::uint32_t bit);
// Niveau 1 : forces des actions (Input.get_action_strength, dans [0, 1]) et masque des
// boutons pressés. Axe horizontal = droite - gauche, vertical = haut - bas.
struct ActionStrengths {
double left = 0;
double right = 0;
double down = 0;
double up = 0;
std::uint32_t buttons = 0;
};
[[nodiscard]] three_d::PlayerInput map_actions(const ActionStrengths& strengths) noexcept;
// Niveau 2 : champs déjà extraits du Dictionary renvoyé par le script. nullopt si buttons
// sort de [0, 2^32-1] ou si un axe n'est pas fini ; les axes au-delà de [-1, 1] sont bornés.
[[nodiscard]] std::optional<three_d::PlayerInput> map_script_result(std::int64_t buttons, double move_x,
double move_y) noexcept;
} // namespace sequa::godot
@@ -8,6 +8,51 @@ config_version=5
config/name="SEQUA"
run/main_scene="res://main.tscn"
[input]
; Correspondance par défaut des entrées du joueur (ADR 0011, include/sequa/godot/input_mapping.hpp) :
; touches en position physique (ZQSD sur AZERTY, WASD sur QWERTY), flèches, espace, manette.
; Actions facultatives en plus : sequa_button_1 à sequa_button_31 (bits 1 à 31).
sequa_move_left={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":13,"pressure":0.0,"pressed":false,"script":null)
]
}
sequa_move_right={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":14,"pressure":0.0,"pressed":false,"script":null)
]
}
sequa_move_down={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":12,"pressure":0.0,"pressed":false,"script":null)
]
}
sequa_move_up={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":11,"pressure":0.0,"pressed":false,"script":null)
]
}
sequa_jump={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
]
}
[rendering]
renderer/rendering_method="forward_plus"
+17 -1
View File
@@ -6,6 +6,7 @@
#if SEQUA_GODOT_RUNTIME
#include "godot_host.hpp"
#include "godot_input.hpp"
#include "godot_scene.hpp"
#endif
@@ -17,9 +18,10 @@ using three_d::FrameBuffer;
#if SEQUA_GODOT_RUNTIME
struct GodotEngineAdapter::Runtime {
// Ordre de destruction : les nœuds de la scène avant le moteur.
// Ordre de destruction : entrées et nœuds de la scène avant le moteur.
std::unique_ptr<GodotHost> host;
std::unique_ptr<GodotScene> scene;
std::unique_ptr<GodotInput> input;
};
#else
struct GodotEngineAdapter::Runtime {};
@@ -34,6 +36,7 @@ three_d::EngineCapabilities godot_capabilities()
FrameBuffer::motion_vectors};
caps.windowed = true;
caps.headless = false; // --headless de Godot coupe le rendu : vrai headless à faire dans le fork (ADR 0007)
caps.player_input = true; // InputMap du projet ou script SequaInput (ADR 0011)
return caps;
}
@@ -64,6 +67,7 @@ std::optional<Error> GodotEngineAdapter::start(const three_d::EngineConfig& conf
auto runtime = std::make_unique<Runtime>();
runtime->host = std::move(std::get<std::unique_ptr<GodotHost>>(outcome));
runtime->scene = std::make_unique<GodotScene>(runtime->host->scene_root());
runtime->input = std::make_unique<GodotInput>(runtime->host->scene_root());
runtime_ = std::move(runtime);
scene_.clear();
running_ = true;
@@ -127,4 +131,16 @@ three_d::FrameOutcome GodotEngineAdapter::render_frame(const three_d::FrameReque
#endif
}
std::optional<three_d::PlayerInput> GodotEngineAdapter::poll_input()
{
if (!running_) {
return std::nullopt;
}
#if SEQUA_GODOT_RUNTIME
return runtime_->input->read();
#else
return std::nullopt;
#endif
}
} // namespace sequa::godot
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: MPL-2.0
#include "godot_input.hpp"
#include "sequa/godot/input_mapping.hpp"
#include <godot_cpp/classes/input.hpp>
#include <godot_cpp/classes/input_map.hpp>
#include <godot_cpp/classes/node.hpp>
#include <godot_cpp/classes/node3d.hpp>
#include <godot_cpp/classes/scene_tree.hpp>
#include <godot_cpp/classes/window.hpp>
#include <godot_cpp/core/object.hpp>
#include <godot_cpp/variant/dictionary.hpp>
#include <godot_cpp/variant/node_path.hpp>
#include <godot_cpp/variant/variant.hpp>
#include <cstdio>
#include <optional>
#include <string>
namespace sequa::godot {
namespace {
// Valeur numérique d'une clé du Dictionary renvoyé par le script : absente = 0, entier ou
// flottant accepté (entier seulement si `integer_only`), autre type = invalide.
template <class T>
std::optional<T> number_field(const ::godot::Dictionary& result, const char* key, bool integer_only)
{
const ::godot::String name(key);
if (!result.has(name)) {
return T{0};
}
const ::godot::Variant value = result[name];
if (value.get_type() == ::godot::Variant::INT) {
return static_cast<T>(static_cast<std::int64_t>(value));
}
if (!integer_only && value.get_type() == ::godot::Variant::FLOAT) {
return static_cast<T>(static_cast<double>(value));
}
return std::nullopt;
}
} // namespace
GodotInput::GodotInput(::godot::Node3D* scene_root) : scene_root_(scene_root)
{
::godot::InputMap* map = ::godot::InputMap::get_singleton();
// Les actions absentes ne sont jamais interrogées : Godot signalerait une erreur à
// chaque image (action inexistante).
const auto bind = [map](Action& action, const std::string& name) {
action.name = ::godot::StringName(name.c_str());
action.exists = map->has_action(action.name);
};
bind(left_, action_move_left);
bind(right_, action_move_right);
bind(down_, action_move_down);
bind(up_, action_move_up);
for (std::uint32_t bit = 0; bit < action_button_count; ++bit) {
bind(buttons_[bit], button_action_name(bit));
}
::godot::Node* scene = scene_root_->get_tree()->get_current_scene();
::godot::Node* node = scene != nullptr ? scene->get_node_or_null(::godot::NodePath(input_node_name)) : nullptr;
if (node != nullptr) {
if (node->has_method(input_method_name)) {
script_node_ = ::godot::ObjectID(node->get_instance_id());
std::fprintf(stderr, "adaptateur Godot : correspondance des entrées par le script %s.%s()\n",
input_node_name, input_method_name);
} else {
std::fprintf(stderr, "adaptateur Godot : nœud %s sans méthode %s(), InputMap utilisée\n",
input_node_name, input_method_name);
}
std::fflush(stderr);
}
}
three_d::PlayerInput GodotInput::read()
{
if (!scene_root_->get_window()->has_focus()) {
return {};
}
return script_node_.is_valid() ? read_script() : read_actions();
}
three_d::PlayerInput GodotInput::read_actions() const
{
const ::godot::Input* input = ::godot::Input::get_singleton();
const auto strength = [input](const Action& action) {
return action.exists ? static_cast<double>(input->get_action_strength(action.name)) : 0.0;
};
ActionStrengths strengths;
strengths.left = strength(left_);
strengths.right = strength(right_);
strengths.down = strength(down_);
strengths.up = strength(up_);
for (std::uint32_t bit = 0; bit < action_button_count; ++bit) {
if (buttons_[bit].exists && input->is_action_pressed(buttons_[bit].name)) {
strengths.buttons |= 1u << bit;
}
}
return map_actions(strengths);
}
three_d::PlayerInput GodotInput::read_script()
{
::godot::Object* node = ::godot::ObjectDB::get_instance(static_cast<uint64_t>(script_node_));
if (node == nullptr) {
report_script_error("nœud libéré");
return {};
}
const ::godot::Variant result = node->call(::godot::StringName(input_method_name));
if (result.get_type() != ::godot::Variant::DICTIONARY) {
report_script_error("Dictionary attendu");
return {};
}
const ::godot::Dictionary fields = result;
const auto buttons = number_field<std::int64_t>(fields, "buttons", true);
const auto move_x = number_field<double>(fields, "move_x", false);
const auto move_y = number_field<double>(fields, "move_y", false);
if (!buttons || !move_x || !move_y) {
report_script_error("buttons : int, move_x et move_y : float attendus");
return {};
}
const std::optional<three_d::PlayerInput> mapped = map_script_result(*buttons, *move_x, *move_y);
if (!mapped) {
report_script_error("buttons hors de [0, 2^32-1] ou axe non fini");
return {};
}
return *mapped;
}
void GodotInput::report_script_error(const char* reason)
{
// Une fois seulement : le script est appelé à chaque image.
if (!script_error_reported_) {
script_error_reported_ = true;
std::fprintf(stderr, "adaptateur Godot : résultat invalide de %s.%s() (%s), entrée neutre\n",
input_node_name, input_method_name, reason);
std::fflush(stderr);
}
}
} // namespace sequa::godot
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: MPL-2.0
#pragma once
#include "sequa/3d/input.hpp"
#include <godot_cpp/core/object_id.hpp>
#include <godot_cpp/variant/string_name.hpp>
#include <array>
#include <cstdint>
namespace godot {
class Node3D;
} // namespace godot
namespace sequa::godot {
// Lecture des entrées du joueur dans Godot et traduction en actions SEQUA (ADR 0011,
// niveaux décrits dans input_mapping.hpp). À appeler après GodotInstance::iteration(), qui
// a traité les événements de la fenêtre.
class GodotInput {
public:
// scene_root : nœud SequaScene ; le nœud SequaInput est cherché à la racine de la scène
// principale, une fois, au démarrage.
explicit GodotInput(::godot::Node3D* scene_root);
// Entrée neutre si la fenêtre n'a pas le focus.
[[nodiscard]] three_d::PlayerInput read();
private:
[[nodiscard]] three_d::PlayerInput read_actions() const;
[[nodiscard]] three_d::PlayerInput read_script();
void report_script_error(const char* reason);
::godot::Node3D* scene_root_ = nullptr;
::godot::ObjectID script_node_; // nœud SequaInput (identifiant : le script peut le libérer)
bool script_error_reported_ = false;
struct Action {
::godot::StringName name;
bool exists = false;
};
Action left_, right_, down_, up_;
std::array<Action, 32> buttons_{};
};
} // namespace sequa::godot
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/godot/input_mapping.hpp"
#include <cmath>
namespace sequa::godot {
std::string button_action_name(std::uint32_t bit)
{
return bit == 0 ? std::string(action_jump) : "sequa_button_" + std::to_string(bit);
}
three_d::PlayerInput map_actions(const ActionStrengths& strengths) noexcept
{
three_d::PlayerInput input;
input.buttons = strengths.buttons;
input.move_x = three_d::quantize_axis(strengths.right - strengths.left);
input.move_y = three_d::quantize_axis(strengths.up - strengths.down);
return input;
}
std::optional<three_d::PlayerInput> map_script_result(std::int64_t buttons, double move_x, double move_y) noexcept
{
if (buttons < 0 || buttons > 0xFFFFFFFFLL || !std::isfinite(move_x) || !std::isfinite(move_y)) {
return std::nullopt;
}
three_d::PlayerInput input;
input.buttons = static_cast<std::uint32_t>(buttons);
input.move_x = three_d::quantize_axis(move_x);
input.move_y = three_d::quantize_axis(move_y);
return input;
}
} // namespace sequa::godot
@@ -16,6 +16,13 @@ TEST_CASE("GodotEngineAdapter : capacites declarees sans demarrer le moteur")
// Forward+ n'a pas de G-buffer classique (docs §7.2) : pas d'albedo ni d'identifiants.
CHECK_FALSE(caps.buffers.contains(three_d::FrameBuffer::albedo));
CHECK_FALSE(caps.buffers.contains(three_d::FrameBuffer::object_ids));
CHECK(caps.player_input); // ADR 0011
}
TEST_CASE("GodotEngineAdapter : aucune entree tant que le moteur n'est pas demarre")
{
godot::GodotEngineAdapter adapter;
CHECK_FALSE(adapter.poll_input().has_value());
}
TEST_CASE("GodotEngineAdapter : construit sans hebergement, start() non implemente")
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: MPL-2.0
#include "sequa/godot/input_mapping.hpp"
#include <doctest/doctest.h>
#include <limits>
using namespace sequa::godot;
using sequa::three_d::axis_max;
using sequa::three_d::PlayerInput;
TEST_CASE("Godot : noms des actions de boutons")
{
CHECK(button_action_name(0) == "sequa_jump");
CHECK(button_action_name(1) == "sequa_button_1");
CHECK(button_action_name(31) == "sequa_button_31");
}
TEST_CASE("Godot : niveau 1, forces des actions vers PlayerInput")
{
CHECK(map_actions({}).is_neutral());
ActionStrengths right;
right.right = 1.0;
CHECK(map_actions(right) == PlayerInput{0, axis_max, 0});
ActionStrengths both; // gauche et droite ensemble s'annulent
both.left = 1.0;
both.right = 1.0;
CHECK(map_actions(both).move_x == 0);
ActionStrengths analog; // manette a mi-course vers le bas, saut presse
analog.down = 0.5;
analog.buttons = 1;
CHECK(map_actions(analog) == PlayerInput{1, 0, -16384});
}
TEST_CASE("Godot : niveau 2, resultat du script")
{
CHECK(map_script_result(0, 0.0, 0.0) == PlayerInput{});
CHECK(map_script_result(0xFFFFFFFFLL, -1.0, 1.0) == PlayerInput{0xFFFFFFFFu, -axis_max, axis_max});
CHECK(map_script_result(5, 2.0, -7.0) == PlayerInput{5, axis_max, -axis_max}); // axes bornes
CHECK_FALSE(map_script_result(-1, 0.0, 0.0).has_value());
CHECK_FALSE(map_script_result(0x100000000LL, 0.0, 0.0).has_value());
CHECK_FALSE(map_script_result(0, std::numeric_limits<double>::quiet_NaN(), 0.0).has_value());
CHECK_FALSE(map_script_result(0, 0.0, std::numeric_limits<double>::infinity()).has_value());
}
+6 -1
View File
@@ -6,7 +6,7 @@ Spécification : [docs/specs/protocole-core-rendu-0.1.md](../docs/specs/protocol
| Fichier | Rôle |
|---|---|
| `src/main.cpp` | Options, boucle : deltas reçus → `apply()` → `render_frame()`, mesures toutes les secondes |
| `src/main.cpp` | Options, boucle : deltas reçus → `apply()` → `render_frame()` → `poll_input()` (`render/input` si les entrées changent), mesures toutes les secondes |
| `src/core_link.*` | Client WebSocket : poignée de main, deltas poussés par le Core empilés pour la boucle |
| `src/adapter_library.*` | Chargement de la bibliothèque d'adaptateur, point d'entrée C versionné |
@@ -18,8 +18,13 @@ Spécification : [docs/specs/protocole-core-rendu-0.1.md](../docs/specs/protocol
# Sans fenêtre : vue de côté en vidéo à 10 images/s (ffmpeg facultatif, sinon images PPM)
.\bin\windows-x64\debug\core\sequa-core.exe --render debug --render-headless --render-fps 10 `
--render-options '{\"output\": \"build/render-debug.mp4\"}'
# Entrées scriptées (sans clavier) : le joueur court à droite à partir de l'image 20, saute à l'image 60
.\bin\windows-x64\debug\core\sequa-core.exe --render debug --render-headless --render-fps 30 `
--render-options '{\"input\": [{\"frame\": 20, \"moveX\": 32767}, {\"frame\": 60, \"buttons\": 1}]}'
```
Avec Godot, on joue dans la fenêtre (clavier ou manette, voir [engines/godot/adapter](../engines/godot/adapter/README.md#entrées-du-joueur-adr-0011)).
## État
**PROVISOIRE** : JSON, pas d'interpolation entre deux ticks, pas de reconnexion (une connexion perdue arrête le rendu après le délai de grâce, le Core le relance), pas encore de tampons de rendu (étape 5).
+1 -1
View File
@@ -17,7 +17,7 @@
namespace sequa::render {
inline constexpr std::string_view protocol_version = "0.1.0";
inline constexpr std::string_view protocol_version = "0.2.0"; // 0.2 : render/input (ADR 0011)
inline constexpr std::string_view client_name = "sequa-render";
// Événements reçus du Core, empilés par le fil du client et consommés par la boucle de rendu.
+13
View File
@@ -158,6 +158,9 @@ int run(const Options& options)
{"height", options.height}});
three_d::SceneState mirror;
const bool forward_input = adapter.capabilities().player_input;
// Départ neutre : un rendu qui démarre n'écrase pas une entrée tenue par Studio.
three_d::PlayerInput last_input;
std::vector<std::uint64_t> latency_us; // fin du tick -> delta reçu (transport)
std::vector<std::uint64_t> apply_us; // fin du tick -> delta appliqué (attente de l'image comprise)
std::vector<double> cpu_ms;
@@ -239,6 +242,16 @@ int run(const Options& options)
cpu_ms.push_back(std::get<three_d::FrameResult>(outcome).measurements.cpu_ms);
++frames_since_report;
// Entrées du joueur (ADR 0011) : envoyées seulement quand elles changent, le Core
// maintenant la dernière reçue.
if (forward_input) {
if (const std::optional<three_d::PlayerInput> input = adapter.poll_input();
input && !(*input == last_input)) {
link.send("render/input", three_d::wire::encode_player_input({*input, render::monotonic_us()}));
last_input = *input;
}
}
if (Clock::now() - last_report >= std::chrono::seconds(1)) {
const Percentiles cpu = take_percentiles(cpu_ms);
const Percentiles latency = take_percentiles(latency_us);
+2
View File
@@ -20,6 +20,8 @@ export interface RenderEngine {
readonly buffers: readonly string[];
readonly windowed: boolean;
readonly headless: boolean;
/** Le moteur capte les entrées du joueur dans sa fenêtre (ADR 0011) ; absent : inconnu. */
readonly playerInput?: boolean;
}
/** Mesures de la dernière seconde, remontées par le rendu. */
+6 -2
View File
@@ -258,7 +258,7 @@ function decodeEngine(value: unknown): RenderEngine | null {
return null;
}
const { name, version, buffers, windowed, headless } = value;
const { name, version, buffers, windowed, headless, playerInput } = value;
if (!isNonEmptyString(name) || !isNonEmptyString(version)) {
return null;
@@ -272,7 +272,11 @@ function decodeEngine(value: unknown): RenderEngine | null {
return null;
}
return { name, version, buffers, windowed, headless };
if (playerInput !== undefined && typeof playerInput !== 'boolean') {
return null;
}
return { name, version, buffers, windowed, headless, ...(playerInput === undefined ? {} : { playerInput }) };
}
/** Mesures de la dernière seconde remontées par « render/frames » (§9). */
+10
View File
@@ -33,6 +33,7 @@ const ADAPTER_LABELS: Record<RenderAdapter, string> = {
const FACT_TERMS = [
'Moteur',
'Fenêtre',
'Entrées du joueur',
'Taille',
'Images par seconde',
'Latence transport',
@@ -118,6 +119,14 @@ function windowLabel(engine: RenderEngine | undefined): string {
return engine.headless ? 'Sans fenêtre' : NO_VALUE;
}
function playerInputLabel(engine: RenderEngine | undefined): string {
if (engine?.playerInput === undefined) {
return NO_VALUE;
}
return engine.playerInput ? 'Captées par le moteur' : 'Non captées';
}
/**
* Modèle du panneau : pur, sans DOM, testable dans un environnement node.
* Les drapeaux suivent le contrat : start exige un Core connecté, aucune
@@ -169,6 +178,7 @@ export function renderPanelModel(state: StudioState): RenderPanelModel {
value: engine === undefined ? NO_VALUE : `${engine.name} ${engine.version}`,
},
{ term: 'Fenêtre', value: windowLabel(engine) },
{ term: 'Entrées du joueur', value: playerInputLabel(engine) },
{
term: 'Taille',
value: width === undefined || height === undefined ? NO_VALUE : `${width} × ${height}`,
+24
View File
@@ -274,6 +274,19 @@ describe('decode', () => {
}
});
it('décode playerInput du moteur, facultatif (ADR 0011)', () => {
const engine = { name: 'godot', version: '4.7.2', buffers: ['color'], windowed: true, headless: false };
const decodeEngine = (value: object) => {
const result = decode(JSON.stringify({ v: 1, id: 'r', type: 'render/status', payload: { ...makeStatus(), engine: value } }));
expect(result.ok).toBe(true);
return result.ok && result.message.type === 'render/status' && 'state' in result.message.payload
? (result.message.payload as RenderStatus).engine
: undefined;
};
expect(decodeEngine({ ...engine, playerInput: true })?.playerInput).toBe(true);
expect(decodeEngine(engine)?.playerInput).toBeUndefined();
});
it('refuse un state inconnu', () => {
expectError(
JSON.stringify({
@@ -340,6 +353,17 @@ describe('decode', () => {
}),
'invalid-payload',
);
expectError(
JSON.stringify({
v: 1,
type: 'render/start',
payload: {
...makeStatus(),
engine: { name: 'test', version: '1', buffers: ['color'], windowed: true, headless: false, playerInput: 1 },
},
}),
'invalid-payload',
);
});
it('refuse un adapter invalide dans une réponse', () => {
+10
View File
@@ -191,6 +191,16 @@ describe('renderPanelModel — formatage des faits', () => {
expect(model.facts.find(f => f.term === 'Fenêtre')?.value).toBe('Sans fenêtre');
});
it('entrées du joueur : captées, non captées ou inconnues (ADR 0011)', () => {
const label = (engine: ReturnType<typeof createEngine>) =>
renderPanelModel(
createBaseState({ render: createRenderView({ status: createStatus({ engine }), pending: false }) }),
).facts.find(f => f.term === 'Entrées du joueur')?.value;
expect(label(createEngine({ playerInput: true }))).toBe('Captées par le moteur');
expect(label(createEngine({ playerInput: false }))).toBe('Non captées');
expect(label(createEngine())).toBe(NO_VALUE);
});
it('taille formatée « 1280 × 720 »', () => {
const state = createBaseState({
render: createRenderView({ status: createStatus({ width: 1280, height: 720 }), pending: false }),