diff --git a/docs/benilla-compatibility.md b/docs/benilla-compatibility.md index 692dcec5f..5db795d16 100644 --- a/docs/benilla-compatibility.md +++ b/docs/benilla-compatibility.md @@ -81,6 +81,19 @@ Logon packets use a separate protocol and are covered by the scenario instead. The inventory scanner tests run in `cargo test -p lyracore-gateway`. +The `behaviors` list supplements those source observations with curated gameplay +evidence. Each entry names requests, replies, Module operations, content prerequisites, +test functions and remaining gaps. Message rows link to matching entries through +`behavior_evidence`. An empty list means no curated entry, not missing support. + +The inventory checks that each named test exists as a Rust test declaration. It +records whether the test is ignored by default and marks execution as +`not_run_by_inventory`. It does not run tests or infer that a feature works from a +function name. `support` is a reviewed scope description; even an unavailable feature +can have a passing refusal test. `real_client` remains `unverified` until a separate +Verification records it. Add evidence in +`gateway/examples/benilla_inventory/behaviors.json` as scenarios grow. + ## Transport movement The build-5875 movement codec uses transport flag `0x02000000`, a full 64-bit diff --git a/gateway/examples/benilla_inventory.rs b/gateway/examples/benilla_inventory.rs index a06fa7c8f..ed6b03e0c 100644 --- a/gateway/examples/benilla_inventory.rs +++ b/gateway/examples/benilla_inventory.rs @@ -5,6 +5,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; use syn::visit::{self, Visit}; +#[path = "benilla_inventory/behavior.rs"] +mod behavior; + type References = BTreeMap>; #[derive(Default)] @@ -362,10 +365,15 @@ fn inventory() -> Result { rows.push(row(direction, number, &names, source, &lyra)); } } + let behaviors = behavior::inventory(root)?; + for row in &mut rows { + row["behavior_evidence"] = behavior::matching_behaviors(row, &behaviors); + } Ok(json!({ "benilla_source": package["source"], "scope": "World messages. Source evidence is not scenario verification. Raw numeric sends and macros other than matches! require manual inspection. No decoder reference is itself a requirement to emit a message.", "messages": rows, + "behaviors": behaviors, })) } diff --git a/gateway/examples/benilla_inventory/behavior.rs b/gateway/examples/benilla_inventory/behavior.rs new file mode 100644 index 000000000..5a6e5cfb2 --- /dev/null +++ b/gateway/examples/benilla_inventory/behavior.rs @@ -0,0 +1,131 @@ +//! Test discovery records source presence, not execution or passing results. + +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; +use std::path::Path; +use syn::visit::{self, Visit}; + +pub fn inventory(root: &Path) -> Result { + let mut behaviors: Value = serde_json::from_str(include_str!("behaviors.json"))?; + let rows = behaviors.as_array_mut().context("behavior list")?; + let mut ids = BTreeSet::new(); + for behavior in rows { + let id = behavior["id"].as_str().context("behavior id")?; + if !ids.insert(id.to_owned()) { + bail!("duplicate behavior id: {id}"); + } + for test in behavior["tests"].as_array_mut().context("behavior tests")? { + let path = test["path"].as_str().context("test path")?; + let name = test["function"].as_str().context("test function")?; + let source = std::fs::read_to_string(root.join(path)) + .with_context(|| format!("read behavior evidence {path}"))?; + let ignored = test_evidence(&source, name) + .with_context(|| format!("behavior evidence {path}::{name}"))?; + test["source_present"] = json!(true); + test["ignored_by_default"] = json!(ignored); + test["execution"] = json!("not_run_by_inventory"); + } + } + Ok(behaviors) +} + +pub fn matching_behaviors(message: &Value, behaviors: &Value) -> Value { + let field = if message["direction"] == "client_to_gateway" { + "requests" + } else { + "replies" + }; + json!(behaviors + .as_array() + .into_iter() + .flatten() + .filter(|behavior| behavior[field] + .as_array() + .is_some_and(|opcodes| opcodes.contains(&message["opcode"]))) + .map(|behavior| &behavior["id"]) + .collect::>()) +} + +fn test_evidence(source: &str, name: &str) -> Result { + struct Tests<'a> { + name: &'a str, + matches: Vec, + } + impl<'ast> Visit<'ast> for Tests<'_> { + fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) { + let has_attribute = |name: &str| { + item.attrs.iter().any(|attribute| { + attribute + .path() + .segments + .last() + .is_some_and(|segment| segment.ident == name) + }) + }; + if item.sig.ident == self.name && has_attribute("test") { + self.matches.push(has_attribute("ignore")); + } + visit::visit_item_fn(self, item); + } + } + let mut tests = Tests { + name, + matches: Vec::new(), + }; + tests.visit_file(&syn::parse_file(source)?); + match tests.matches.as_slice() { + [ignored] => Ok(*ignored), + [] => bail!("named test is absent: {name}"), + _ => bail!("named test is ambiguous: {name}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evidence_requires_a_test_declaration() { + let source = r##" + // #[test] fn missing() {} + fn ordinary() { let _ = "#[test] fn missing() {}"; } + #[cfg(test)] mod tests { + #[test] fn scenario() {} + #[tokio::test] #[ignore = "private standalone"] async fn durable() {} + } + "##; + assert!(test_evidence(source, "missing").is_err()); + assert!(test_evidence(source, "ordinary").is_err()); + assert!(!test_evidence(source, "scenario").unwrap()); + assert!(test_evidence(source, "durable").unwrap()); + } + + #[test] + fn evidence_refuses_ambiguous_test_names() { + let source = "mod a { #[test] fn same() {} } mod b { #[test] fn same() {} }"; + assert!(test_evidence(source, "same").is_err()); + } + + #[test] + fn behavior_links_respect_opcode_direction() { + let behaviors = json!([ + {"id": "request", "requests": ["0x0040"], "replies": []}, + {"id": "reply", "requests": [], "replies": ["0x0040"]} + ]); + assert_eq!( + matching_behaviors( + &json!({"direction": "gateway_to_client", "opcode": "0x0040"}), + &behaviors + ), + json!(["reply"]) + ); + assert_eq!( + matching_behaviors( + &json!({"direction": "client_to_gateway", "opcode": "0xFFFF"}), + &behaviors + ), + json!([]) + ); + } +} diff --git a/gateway/examples/benilla_inventory/behaviors.json b/gateway/examples/benilla_inventory/behaviors.json new file mode 100644 index 000000000..14e86f09b --- /dev/null +++ b/gateway/examples/benilla_inventory/behaviors.json @@ -0,0 +1,300 @@ +[ + { + "id": "failed_transfer", + "support": "partial", + "requests": [ + "0x00DC" + ], + "replies": [ + "0x0040" + ], + "durable_operations": [], + "prerequisites": [ + "An active Character and a pending Transfer that cannot select its destination." + ], + "tests": [ + { + "path": "gateway/src/world/benilla_tests.rs", + "function": "benilla_failed_transfer_reports_reason_without_a_destination_map", + "rung": "headless_client_with_fake_store" + }, + { + "path": "gateway/src/codec/movement.rs", + "function": "vanilla_transfer_abort_contains_only_the_reason", + "rung": "codec" + } + ], + "remaining": [ + "Real-client loading-screen recovery has not been checked." + ], + "real_client": "unverified" + }, + { + "id": "quest_reward", + "support": "partial", + "requests": [ + "0x0189", + "0x018E" + ], + "replies": [ + "0x0191" + ], + "durable_operations": [ + "module/src/gw.rs::gw_accept_quest", + "module/src/gw.rs::gw_turn_in_quest" + ], + "prerequisites": [ + "Quest templates, objectives, giver relations and reward item templates are imported." + ], + "tests": [ + { + "path": "gateway/src/world/benilla_tests.rs", + "function": "benilla_accepts_a_quest_requests_its_reward_and_collects_it", + "rung": "headless_client_with_fake_store" + }, + { + "path": "gateway/src/world/benilla_tests/durable.rs", + "function": "benilla_durable_kill_quest_loot_rewards_and_reconnect", + "rung": "headless_client_with_module" + }, + { + "path": "module/tests/quest_choice_reward.rs", + "function": "durable_choice_turn_in_grants_the_selected_item_with_all_other_rewards", + "rung": "durable_module" + } + ], + "remaining": [ + "These scenarios do not establish every quest objective or scripted quest." + ], + "real_client": "unverified" + }, + { + "id": "corpse_loot", + "support": "partial", + "requests": [ + "0x015D", + "0x015E", + "0x0108" + ], + "replies": [ + "0x0160" + ], + "durable_operations": [ + "module/src/gw.rs::gw_open_creature_loot", + "module/src/gw.rs::gw_loot_money", + "module/src/gw.rs::gw_take_loot" + ], + "prerequisites": [ + "Creature loot rows and item templates are imported." + ], + "tests": [ + { + "path": "gateway/src/world/benilla_tests.rs", + "function": "benilla_loots_money_and_an_item_then_moves_it_in_inventory", + "rung": "headless_client_with_fake_store" + }, + { + "path": "gateway/src/world/benilla_tests/durable.rs", + "function": "benilla_durable_kill_quest_loot_rewards_and_reconnect", + "rung": "headless_client_with_module" + } + ], + "remaining": [ + "These scenarios cover one Character's corpse loot, not every group loot policy." + ], + "real_client": "unverified" + }, + { + "id": "transport_movement", + "support": "partial", + "requests": [ + "0x00B5", + "0x00EE" + ], + "replies": [ + "0x00B5", + "0x00EE" + ], + "durable_operations": [ + "module/src/gw.rs::gw_movement_update" + ], + "prerequisites": [ + "World entry and AOI subscriptions are active." + ], + "tests": [ + { + "path": "gateway/src/world/benilla_tests.rs", + "function": "benilla_transport_movement_survives_gateway_decode_and_relay", + "rung": "codec" + }, + { + "path": "gateway/src/world/benilla_tests/durable.rs", + "function": "benilla_durable_peer_discovery_movement_and_aoi", + "rung": "headless_client_with_module" + } + ], + "remaining": [ + "Boat simulation, transport membership and acknowledgement counters remain outside these scenarios." + ], + "real_client": "unverified" + }, + { + "id": "hunter_pet_lifecycle", + "support": "partial", + "requests": [ + "0x0175" + ], + "replies": [ + "0x0179" + ], + "durable_operations": [ + "module/src/gw.rs::gw_pet_command" + ], + "prerequisites": [ + "A tameable creature, its template and the Hunter's spells are present." + ], + "tests": [ + { + "path": "module/tests/hunter_pet.rs", + "function": "tame_dismiss_and_retame_preserve_one_durable_identity_and_clean_live_state", + "rung": "durable_module" + } + ], + "remaining": [ + "Pet bar editing, autocast and stables remain separate work." + ], + "real_client": "unverified" + }, + { + "id": "battleground_queue", + "support": "unavailable", + "requests": [ + "0x02D3" + ], + "replies": [ + "0x02D4" + ], + "durable_operations": [], + "prerequisites": [], + "tests": [ + { + "path": "gateway/src/world/benilla_tests.rs", + "function": "benilla_battleground_queries_report_no_instances_or_queues", + "rung": "headless_client_with_fake_store" + } + ], + "remaining": [ + "The test proves the empty response, not a playable battleground." + ], + "real_client": "unverified" + }, + { + "id": "unavailable_pet_controls", + "support": "unavailable", + "requests": [ + "0x0174" + ], + "replies": [ + "0x0096" + ], + "durable_operations": [], + "prerequisites": [], + "tests": [ + { + "path": "gateway/src/world/benilla_tests.rs", + "function": "benilla_missing_gameplay_returns_a_specific_refusal_without_disconnect", + "rung": "headless_client_with_fake_store" + } + ], + "remaining": [ + "Pet bar changes receive an explicit unavailable response." + ], + "real_client": "unverified" + }, + { + "id": "hunter_pet_rename", + "support": "partial", + "requests": [ + "0x0177", + "0x0052" + ], + "replies": [ + "0x0178", + "0x0053", + "0x00A9" + ], + "durable_operations": [ + "module/src/creatures/hunter_pet.rs::gw_pet_rename" + ], + "prerequisites": [ + "The Character owns a live Hunter pet with its rename still available." + ], + "tests": [ + { + "path": "gateway/src/world/combat_tests.rs", + "function": "pet_rename_stores_the_name_once_and_answers_nothing", + "rung": "store" + }, + { + "path": "module/tests/hunter_pet.rs", + "function": "a_hunter_renames_its_own_pet_once_and_the_name_outlives_logout", + "rung": "durable_module" + }, + { + "path": "gateway/src/codec/pet/subscriptions.rs", + "function": "renamed_pet_refreshes_known_observers_without_owner_only_fields", + "rung": "relay" + }, + { + "path": "module/tests/hunter_pet.rs", + "function": "existing_live_pet_recovers_rename_eligibility_without_losing_other_flags", + "rung": "durable_module" + } + ], + "remaining": [ + "The complete client name-cache refresh and Rename Pet menu need real-client Verification.", + "Call Pet and pet restoration after login are not implemented. The name persists after logout, but the pet does not return.", + "Reserved-name and profanity lists are not enforced." + ], + "real_client": "unverified" + }, + { + "id": "gossip_conditions", + "support": "partial", + "requests": [ + "0x017B", + "0x017C" + ], + "replies": [ + "0x017D", + "0x017E" + ], + "durable_operations": [ + "module/src/gw.rs::gw_gossip_select" + ], + "prerequisites": [ + "Gossip options and their referenced conditions are imported." + ], + "tests": [ + { + "path": "gateway/src/world/npc_tests.rs", + "function": "a_quest_rewarded_after_hello_refuses_the_vendor_click_before_its_action", + "rung": "headless_client_with_fake_store" + }, + { + "path": "module/tests/gossip_conditions.rs", + "function": "imported_conditions_gate_current_quest_state_and_the_hook_in_one_transaction", + "rung": "durable_module" + }, + { + "path": "module/tests/gossip_conditions.rs", + "function": "a_menu_switch_refuses_colliding_rows_and_synthesized_options_before_the_hook", + "rung": "durable_module" + } + ], + "remaining": [ + "Only documented condition kinds are supported. Existing rows need a reimport to recover discarded references." + ], + "real_client": "unverified" + } +]