Compare commits

...

4 Commits

38 changed files with 4741 additions and 47 deletions
+63 -7
View File
@@ -22,7 +22,39 @@ jobs:
if: inputs.os == 'macos' || inputs.os == 'both'
runs-on: macos-latest
steps:
- name: Проверка релиза
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ inputs.tag }}
run: |
set -e
if ! [[ "$TAG" =~ ^build-[0-9]+$ ]]; then echo "::error::тег '$TAG' не похож на build-N"; exit 1; fi
REL_JSON=$(mktemp)
CODE=$(curl -s -o "$REL_JSON" -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG")
if [ "$CODE" != "200" ]; then echo "::error::релиза с тегом $TAG нет (HTTP $CODE): сначала дождись сборки build.yml на main"; exit 1; fi
python3 - "$TAG" "$REL_JSON" <<'PY'
import json, os, sys
tag, path = sys.argv[1], sys.argv[2]
r = json.load(open(path))
assets = [a.get('name') for a in (r.get('assets') or [])]
if r.get('tag_name') != tag or r.get('name') != tag or 'airdef.hmod' not in assets:
print('::error::релиз %s не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем' % tag)
sys.exit(1)
with open(os.environ['GITHUB_ENV'], 'a') as f:
f.write('RELEASE_ID=%s\nRELEASE_SHA=%s\n' % (r['id'], r.get('target_commitish', '')))
PY
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ inputs.tag }}
- name: Проверка коммита
env:
TAG: ${{ inputs.tag }}
run: |
set -e
HEAD_SHA=$(git rev-parse HEAD)
if [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$HEAD_SHA" != "$RELEASE_SHA" ]; then echo "::error::тег $TAG указывает на $HEAD_SHA, а релиз создан из $RELEASE_SHA"; exit 1; fi
echo "собираю $TAG = $HEAD_SHA"
- name: Rust
run: |
command -v cargo || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
@@ -47,11 +79,10 @@ jobs:
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ inputs.tag }}
run: |
set -e
ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef-macos-arm64.tar.gz" "$API/releases/$ID/assets?name=airdef-macos-arm64.tar.gz" > /dev/null
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/airdef-macos-arm64.tar.gz" "$API/releases/$RELEASE_ID/assets?name=airdef-macos-arm64.tar.gz" > /dev/null
game-windows:
if: inputs.os == 'windows' || inputs.os == 'both'
@@ -60,7 +91,34 @@ jobs:
run:
shell: pwsh
steps:
- name: Проверка релиза
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ inputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
if ($env:TAG -notmatch '^build-\d+$') { Write-Output "::error::тег '$env:TAG' не похож на build-N"; exit 1 }
$h = @{ Authorization = "token $env:TOKEN" }
try { $r = Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG" }
catch { Write-Output "::error::релиза с тегом $env:TAG нет: сначала дождись сборки build.yml на main"; exit 1 }
$assets = @($r.assets | ForEach-Object { $_.name })
if ($r.tag_name -ne $env:TAG -or $r.name -ne $env:TAG -or ($assets -notcontains 'airdef.hmod')) {
Write-Output "::error::релиз $env:TAG не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем"; exit 1
}
"RELEASE_ID=$($r.id)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
"RELEASE_SHA=$($r.target_commitish)" | Out-File -Append -Encoding utf8 $env:GITHUB_ENV
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ inputs.tag }}
- name: Проверка коммита
env:
TAG: ${{ inputs.tag }}
run: |
$ErrorActionPreference = 'Stop'
$head = git rev-parse HEAD
if ($env:RELEASE_SHA -match '^[0-9a-f]{40}$' -and $head -ne $env:RELEASE_SHA) { Write-Output "::error::тег $env:TAG указывает на $head, а релиз создан из $env:RELEASE_SHA"; exit 1 }
Write-Output "собираю $env:TAG = $head"
- name: Rust
run: |
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
@@ -88,9 +146,7 @@ jobs:
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ inputs.tag }}
run: |
$h = @{ Authorization = "token $env:TOKEN" }
$id = (Invoke-RestMethod -Headers $h "$env:API/releases/tags/$env:TAG").id
curl.exe -sf -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@out\airdef-windows-x86_64.zip" "$env:API/releases/$id/assets?name=airdef-windows-x86_64.zip"
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
curl.exe -sf -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@out\airdef-windows-x86_64.zip" "$env:API/releases/$env:RELEASE_ID/assets?name=airdef-windows-x86_64.zip"
+34 -3
View File
@@ -25,7 +25,39 @@ jobs:
FREE=$(df --output=avail -BG / | tail -1 | tr -dc 0-9)
echo "свободно: ${FREE} ГБ"
if [ "$FREE" -lt 15 ]; then echo "::error::на сервере свободно ${FREE} ГБ, нужно хотя бы 15: сборка игры забьёт диск и уронит Gitea"; exit 1; fi
- name: Проверка релиза
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ inputs.tag }}
run: |
set -e
if ! [[ "$TAG" =~ ^build-[0-9]+$ ]]; then echo "::error::тег '$TAG' не похож на build-N"; exit 1; fi
REL_JSON=$(mktemp)
CODE=$(curl -s -o "$REL_JSON" -w '%{http_code}' -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG")
if [ "$CODE" != "200" ]; then echo "::error::релиза с тегом $TAG нет (HTTP $CODE): сначала дождись сборки build.yml на main"; exit 1; fi
python3 - "$TAG" "$REL_JSON" <<'PY'
import json, os, sys
tag, path = sys.argv[1], sys.argv[2]
r = json.load(open(path))
assets = [a.get('name') for a in (r.get('assets') or [])]
if r.get('tag_name') != tag or r.get('name') != tag or 'airdef.hmod' not in assets:
print('::error::релиз %s не создан build.yml (нужны имя = тег и вложение airdef.hmod): чужой релиз не трогаем' % tag)
sys.exit(1)
with open(os.environ['GITHUB_ENV'], 'a') as f:
f.write('RELEASE_ID=%s\nRELEASE_SHA=%s\n' % (r['id'], r.get('target_commitish', '')))
PY
- uses: actions/checkout@v4
with:
ref: refs/tags/${{ inputs.tag }}
- name: Проверка коммита
env:
TAG: ${{ inputs.tag }}
run: |
set -e
HEAD_SHA=$(git rev-parse HEAD)
if [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] && [ "$HEAD_SHA" != "$RELEASE_SHA" ]; then echo "::error::тег $TAG указывает на $HEAD_SHA, а релиз создан из $RELEASE_SHA"; exit 1; fi
echo "собираю $TAG = $HEAD_SHA"
- name: Системные библиотеки
run: |
SUDO=$(command -v sudo || true)
@@ -45,12 +77,11 @@ jobs:
env:
TOKEN: ${{ secrets.RELEASE_TOKEN || gitea.token }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
TAG: ${{ inputs.tag }}
run: |
set -e
D=airdef-linux-x86_64
mkdir -p "$D" out
cp horizon/target/fast/horizon-client horizon/target/fast/horizon-ctl airdef-mod/target/horizon-mod/airdef.hmod dist/play.sh docs/INSTALL.md "$D/"
tar czf "out/$D.tar.gz" "$D"
ID=$(curl -sf -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/$D.tar.gz" "$API/releases/$ID/assets?name=$D.tar.gz" > /dev/null
# RELEASE_ID взят в шаге «Проверка релиза» (релиз build-N, созданный build.yml)
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@out/$D.tar.gz" "$API/releases/$RELEASE_ID/assets?name=$D.tar.gz" > /dev/null
+1
View File
@@ -5,3 +5,4 @@ airdef-mod/target/
__pycache__/
/out/
.DS_Store
run-home-bench/
+3 -1
View File
@@ -27,7 +27,9 @@ python3 tools/airdef.py play # запустить (аргументы игр
CI: `.gitea/workflows/build.yml` на каждый пуш собирает только мод (`airdef.hmod`), а пуш в `main` публикует его
релизом `build-N`. Игру-форк собирают вручную: `.gitea/workflows/game.yml` (Linux) и `.gitea/workflows/desktop.yml`
(macOS и Windows, на раннерах с метками `macos-latest` / `windows-latest`); оба запускаются из Actions с тегом
готового релиза и дописывают свои архивы в него.
готового релиза `build-N`. Перед сборкой workflow проверяет, что такой релиз создан `build.yml` (имя релиза = тег,
есть `airdef.hmod`), иначе падает с ошибкой; затем выкачивает ровно коммит этого тега (а не ветку, с которой запущен),
сверяет его с коммитом релиза и дописывает архивы только в этот релиз.
## Как играть
+18 -1
View File
@@ -18,6 +18,13 @@ pub enum Terrain {
Hills { base: i32, amp: f64, period: f64 },
}
/// The land as blocks: solid at and below the column's height (what the game would answer `render.sections` with).
impl airdef::sim::world::WorldSource for Terrain {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
Some(y < self.height(x, z))
}
}
impl Terrain {
pub fn height(self, x: i32, z: i32) -> i32 {
match self {
@@ -98,6 +105,9 @@ pub struct Report {
/// Missiles fired by air defence (sum of the systems' `fired`).
pub fired: u32,
pub ground_queries: u64,
/// The block cache's counters (issue #43) and the drones that struck the land (issue #42).
pub world: String,
pub crashes: u32,
pub ground_batches: u64,
pub tick_p50: Duration,
pub tick_p95: Duration,
@@ -163,6 +173,8 @@ pub fn run(sc: &Scenario) -> Report {
let t = Instant::now();
sim.step(&mut ground, None, &mut out);
let wanted = ground.take_wanted();
let sections = ground.world.take_wanted(now);
ground.world.serve(&sc.terrain, sections, now);
let dt = t.elapsed();
times.push(dt);
if planning {
@@ -212,6 +224,8 @@ pub fn run(sc: &Scenario) -> Report {
r.hits = sim.hits;
r.fired = sim.sams.iter().map(|s| s.fired).sum();
r.raids_ordered = ordered.iter().filter(|o| **o).count();
r.world = ground.world.text();
r.crashes = sim.crashes;
r.raids_ended = ended.iter().filter(|o| **o).count();
r.all_ended = r.raids_ended == sc.raids.len();
r.tick_total = times.iter().sum();
@@ -253,7 +267,8 @@ impl Report {
planning: {pl:.1} ms over {pt} ticks\n\
ground queries: {gq} in {gb} batches\n\
non-finite positions: {nan}\n\
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n",
stuck trucks: {stuck:?} (busy truck-ticks {busy})\n\
crashes into the land: {crashes}; {world}\n",
name = self.name,
seed = self.seed,
units = self.units,
@@ -276,6 +291,8 @@ impl Report {
nan = self.nan_positions,
stuck = self.stuck_trucks,
busy = self.truck_busy_ticks,
crashes = self.crashes,
world = self.world,
)
}
pub fn json(&self) -> String {
+27 -2
View File
@@ -1061,6 +1061,25 @@ impl Mod for AirDef {
}
let now = self.sim.tick;
self.ground.answer(render::ground(), now);
// The blocks themselves (issues #42, #43): what changed, and the sections asked for last tick.
let changes = render::world_changes();
self.ground.world.changed(changes.epoch, changes.overflow, &changes.sections);
let sections = render::sections()
.into_iter()
.map(|s| sim::world::SectionAnswer {
sec: (s.x, s.y, s.z),
fill: match s.fill {
render::SectionFill::Unknown => sim::world::Fill::Unknown,
render::SectionFill::Empty => sim::world::Fill::Empty,
render::SectionFill::Full => sim::world::Fill::Full,
render::SectionFill::Mixed => match <[u8; 512]>::try_from(s.bits) {
Ok(bits) => sim::world::Fill::Mixed(Box::new(bits)),
Err(_) => sim::world::Fill::Unknown,
},
},
})
.collect();
self.ground.world.answer(sections, now);
let mut out = Out::default();
self.listener = DVec3::new(p.x, p.y + 1.6, p.z);
self.sim.night = game::get_clock().is_some_and(|c| (13000..23000).contains(&(c.day_time % 24000)));
@@ -1069,6 +1088,10 @@ impl Mod for AirDef {
if !wanted.is_empty() {
render::query_ground(&wanted);
}
let sections = self.ground.world.take_wanted(now);
if !sections.is_empty() {
render::query_sections(render::Policy::Collision, &sections);
}
if let Some(mut r) = self.replay.take() {
r.tick(&self.sim, self.listener, &mut out);
if r.done {
@@ -1451,12 +1474,14 @@ impl Mod for AirDef {
Some(format!("Эффекты: {}", match *v { "off" => "выкл", "hd" => "HD", _ => "обычные" }))
}
["stats"] => Some(format!(
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}",
"В воздухе: {}, ракет: {}, ПВО: {}, сбито: {}, поражений: {}, разбились о препятствия: {}; {}",
self.sim.drones.iter().filter(|d| d.alive()).count(),
self.sim.missiles.len(),
self.sim.sams.len(),
self.sim.kills,
self.sim.hits
self.sim.hits,
self.sim.crashes,
self.ground.world.text()
)),
_ => return Err(HELP.to_string()),
};
+8 -3
View File
@@ -107,6 +107,7 @@ pub fn title(rec: &Recording, i: usize) -> String {
EventKind::Hit { kind, .. } => format!("Прилёт: «{}»", kind.spec().name),
EventKind::SamLost { kind, index, .. } => format!("{} №{} уничтожен", kind.name(), index + 1),
EventKind::Split { .. } => "«Орешник»: разделение боевых блоков".into(),
EventKind::Crash { kind, .. } => format!("«{}» разбивается о препятствие", kind.spec().name),
}
}
@@ -122,6 +123,7 @@ pub fn row(rec: &Recording, i: usize) -> String {
EventKind::Hit { kind, .. } => format!("{}: прилёт", kind.spec().name),
EventKind::SamLost { kind, index, .. } => format!("{}-{} уничтожен", kind.name(), index + 1),
EventKind::Split { .. } => "Орешник: разделение".into(),
EventKind::Crash { kind, .. } => format!("{}: о рельеф", kind.spec().name),
};
format!("{} {what}", clock(e.at as f64))
}
@@ -152,6 +154,7 @@ fn interest(rec: &Recording, i: usize) -> f64 {
power as f64 * 1.5 + if object.is_some() { 6.0 } else if sam.is_some() { 5.0 } else { 0.0 }
}
EventKind::SamLost { .. } => 12.0,
EventKind::Crash { power, .. } => power as f64,
EventKind::Split { drone } => {
// The bus's split, not each block's.
let first = rec.events.iter().position(|o| matches!(o.kind, EventKind::Split { .. }));
@@ -168,6 +171,7 @@ fn clip(rec: &Recording, i: usize) -> Clip {
EventKind::Hit { .. } => (160.0, 60.0),
EventKind::SamLost { .. } => (80.0, 90.0),
EventKind::Split { .. } => (40.0, 170.0),
EventKind::Crash { .. } => (120.0, 60.0),
};
Clip { event: i, from: (at - before).max(0.0), to: (at + after).min(rec.len as f64) }
}
@@ -352,7 +356,7 @@ impl Player {
for e in rec.events.iter().filter(|e| (self.heard..self.t).contains(&(e.at as f64))) {
let event = match e.kind {
EventKind::Kill { .. } => "airdef:blast.air",
EventKind::Hit { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => ["airdef:blast.air", "airdef:blast.medium", "airdef:blast.big", "airdef:blast.heavy", "airdef:blast.huge"][(power as usize).min(4)],
EventKind::SamLost { .. } => "airdef:blast.big",
EventKind::Split { .. } => "airdef:boom.sonic",
};
@@ -412,7 +416,7 @@ impl Player {
}
}
}
EventKind::Hit { drone, .. } => {
EventKind::Hit { drone, .. } | EventKind::Crash { drone, .. } => {
if t < te - 50.0 {
if let Some((eye, look)) = chase(drone, 12.0, 4.0) {
return (eye, look, 55.0, 0);
@@ -672,7 +676,7 @@ impl Player {
s.push(line((x - 2.5, y - 2.5), (x + 2.5, y + 2.5), 1.2, 0xFFFFE040));
s.push(line((x + 2.5, y - 2.5), (x - 2.5, y + 2.5), 1.2, 0xFFFFE040));
}
EventKind::Hit { power, .. } => {
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => {
let r = 2.0 + power as f32;
s.push(fill(x - r, y - r, r * 2.0, r * 2.0, 0xB0FF3020));
}
@@ -705,6 +709,7 @@ impl Player {
let c = match e.kind {
EventKind::Kill { .. } => 0xFFFFE040,
EventKind::Hit { .. } => 0xFFFF3020,
EventKind::Crash { .. } => 0xFF903020,
EventKind::SamLost { .. } => 0xFFFF00FF,
EventKind::Split { .. } => 0xFFFF9020,
};
+65
View File
@@ -28,7 +28,72 @@ impl MissileKind {
}
}
/// A strike that ends farther than this from its aim point misses (the lethal radius of a point target).
pub(super) const MISS_RADIUS: f64 = 3.0;
/// A system's hull, as a cylinder: its radius (× scale) and how far above the ground it reaches (× scale).
const HULL_RADIUS: f64 = 2.2;
const HULL_TOP: f64 = 3.0;
impl DroneKind {
/// Strike accuracy: the CEP (blocks; half the strikes land within it, the scatter is Rayleigh)
/// and the share of strikes with a rare gross navigation fault, which lands beyond `MISS_RADIUS`.
pub(super) fn accuracy(self) -> (f64, f64) {
match self {
DroneKind::Shahed => (1.2, 0.03),
DroneKind::Geran3 => (1.0, 0.03),
DroneKind::Lancet => (0.8, 0.02),
DroneKind::Gerbera => (1.5, 0.04),
DroneKind::Kh101 => (1.3, 0.05),
DroneKind::Kalibr => (1.3, 0.05),
DroneKind::Kh22 => (1.6, 0.06),
DroneKind::Zircon => (1.5, 0.06),
DroneKind::Iskander => (1.4, 0.06),
DroneKind::Kinzhal => (1.5, 0.06),
// The bus and its blocks: the blocks are aimed by the split, so only the bus's own error applies.
DroneKind::Oreshnik | DroneKind::OreshnikBlock | DroneKind::OreshnikSub => (1.6, 0.08),
}
}
}
impl Sim {
/// The aim error of a new strike: drawn once from the seed, the drone keeps it for its whole flight.
pub(super) fn aim_error(&mut self, kind: DroneKind) -> DVec3 {
let (cep, gross) = kind.accuracy();
let angle = self.rng.f() * std::f64::consts::TAU;
let r = if self.rng.chance(gross) {
MISS_RADIUS + cep * (0.5 + 2.5 * self.rng.f())
} else {
// Rayleigh with its median at the CEP.
let u = 1.0 - self.rng.f();
cep * (-2.0 * u.ln() / (2.0 * std::f64::consts::LN_2)).sqrt()
};
DVec3::new(angle.cos() * r, 0.0, angle.sin() * r)
}
/// An armed warhead reached `pos` (its last step was `vel`): a system whose hull it swept through is
/// destroyed at once, whatever the blast would have done to it. A near miss is only splash.
pub(super) fn direct_hit(&mut self, pos: DVec3, vel: DVec3, tnt: u32, out: &mut Out) {
if !self.damage || tnt == 0 {
return;
}
let from = pos - vel;
for i in 0..self.sams.len() {
let sam = &self.sams[i];
if !sam.alive() {
continue;
}
let s = sam.kind.spec().scale;
let hit = (0..=8).any(|k| {
let p = from.lerp(pos, k as f64 / 8.0);
let across = DVec3::new(p.x - sam.pos.x, 0.0, p.z - sam.pos.z).length();
across <= HULL_RADIUS * s && p.y >= sam.pos.y - 1.0 && p.y <= sam.pos.y + HULL_TOP * s
});
if hit {
self.destroy(i, out);
}
}
}
/// A blast of `power` (0 to 4) at `pos`: the systems and objects round it take damage.
pub(super) fn blast_damage(&mut self, pos: DVec3, power: usize, out: &mut Out) {
let (reach, hit) = (REACH[power], HIT[power]);
+93 -6
View File
@@ -19,6 +19,7 @@ mod logistics;
mod orders;
mod units;
mod why;
pub mod world;
#[cfg(test)]
mod regress;
pub use ew::EW_RADIUS;
@@ -211,6 +212,8 @@ pub struct Ground {
asked: std::collections::HashSet<(i32, i32)>,
/// The tick of the last full clean-up (a big cache would otherwise be scanned on every answer).
swept: u64,
/// The blocks themselves (issues #42, #43): what stops a flight.
pub world: world::WorldCache,
}
/// Ticks between full clean-ups of a big ground cache.
@@ -251,10 +254,32 @@ impl Ground {
self.asked.clear();
std::mem::take(&mut self.wanted)
}
/// The first solid block on the way from `a` to `b` (the real blocks, not the height of the land).
pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> world::Sweep {
self.world.sweep(a, b, skip_start, now)
}
/// Has the sections along a flight read before it gets there.
pub fn ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) {
self.world.look_ahead(pos, vel, ticks, now);
}
/// Whether `pos` is in the land by the column's height (`below`, as `at` gave it), given what the sweep
/// of this step saw: over blocks known and open, the height only counts where its top block is known
/// to be open, a fluid's surface (not a roof or a bridge deck above the flight, nor a block unread).
pub fn ground_hit(&mut self, below: Option<f64>, pos: DVec3, offset: f64, sweep: &world::Sweep, now: u64) -> bool {
let Some(g) = below else { return false };
if pos.y > g + offset {
return false;
}
match sweep {
world::Sweep::Clear => self.world.is_known_open(pos.x.floor() as i32, g.floor() as i32 - 1, pos.z.floor() as i32, now),
_ => true,
}
}
/// Explosions change the land: forget what was known round them.
fn forget(&mut self, at: DVec3, radius: i32) {
let (cx, cz) = (at.x.floor() as i32, at.z.floor() as i32);
self.known.retain(|&(x, z), _| (x - cx).abs() > radius || (z - cz).abs() > radius);
self.world.touched(at, f64::from(radius) + 2.0);
}
}
@@ -1172,6 +1197,8 @@ pub struct Sim {
pub tick: u64,
pub kills: u32,
pub hits: u32,
/// Drones that struck the land or a building short of their aim.
pub crashes: u32,
pub launched: u32,
/// Explosions damage the land (primed TNT through commands).
pub damage: bool,
@@ -1225,6 +1252,7 @@ impl Sim {
tick: 0,
kills: 0,
hits: 0,
crashes: 0,
launched: 0,
damage: true,
shader: false,
@@ -1289,6 +1317,8 @@ impl Sim {
fn add_drone(&mut self, kind: DroneKind, from: DVec3, dir: DVec3, target: DVec3) {
let id = self.id();
let spec = kind.spec();
// Its navigation error: it flies to, and strikes at, a point off the aim it was given.
let target = target + self.aim_error(kind);
let (vel, flight) = if spec.ballistic.is_some() {
// An arc from `from` to `target` at the kind's horizontal speed.
let flat = horizontal(target - from);
@@ -1373,7 +1403,7 @@ impl Sim {
self.update_sight(ground);
self.step_lights(out);
self.step_sams(out);
self.step_missiles(out);
self.step_missiles(ground, out);
self.step_fx(ground, out);
self.draw_targets(out);
self.step_wrecks(out);
@@ -1394,10 +1424,14 @@ impl Sim {
// Warheads that reached their aim (for the replays): drone, kind, where, TNT; and splits.
let mut impacts: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new();
let mut splits: Vec<(u32, DVec3)> = Vec::new();
// Drones that struck the land or a building short of their aim: drone, kind, where, TNT.
let mut crashes: Vec<(u32, DroneKind, DVec3, u32)> = Vec::new();
for d in &mut self.drones {
d.age += 1;
let spec = d.kind.spec();
let below = ground.at(d.pos, now);
let from = d.pos;
ground.ahead(d.pos, d.vel, 20.0, now);
// Near its aim it looks at the land there again: a platform or hilltop blown away
// meanwhile, it goes for what is left below, not for the air where it stood.
if d.flight != Flight::Falling
@@ -1412,11 +1446,15 @@ impl Sim {
d.vel *= 0.985;
d.spin += 0.35;
d.pos += d.vel;
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
if let Some((_, p)) = sweep.hit() {
d.pos = p;
}
if self.rng.chance(0.5) {
out.particles("minecraft:large_smoke", d.pos, 0.2, DVec3::new(0.0, 0.05, 0.0), 0.02, 1);
}
out.particles("minecraft:flame", d.pos, 0.25, DVec3::ZERO, 0.02, 2);
if below.is_some_and(|g| d.pos.y <= g + 0.3) || d.age > 600 {
if sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.3, &sweep, now) || d.age > 600 {
// The wreck's warhead may still go off where it lands.
let warhead = if spec.decoy || !self.rng.chance(0.35) { 0 } else { spec.warhead.min(2) };
boom.push((d.pos, warhead, true));
@@ -1439,13 +1477,24 @@ impl Sim {
d.vel = turn(d.vel, d.target - d.pos, spec.turn) * sp;
}
d.pos += d.vel;
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
if let Some((_, p)) = sweep.hit() {
d.pos = p;
}
let speed = d.vel.length();
let landed = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
let hit = d.pos.distance(d.target) < speed.max(2.0)
|| below.is_some_and(|g| d.pos.y <= g + 0.2)
|| landed
|| (d.vel.y < 0.0 && d.pos.y < d.target.y - 1.0 && horizontal(d.target - d.pos).length() < speed * 2.0)
|| d.age > 4000;
if hit {
boom.push((d.pos, spec.warhead, false));
// Into the land or a building short of its aim: a crash, not a hit.
if landed && d.pos.distance(d.target) >= speed.max(2.0) + 6.0 {
crashes.push((d.id, d.kind, d.pos, spec.warhead));
removed.push(d.id);
continue;
}
impacts.push((d.id, d.kind, d.pos, spec.warhead));
removed.push(d.id);
self.hits += 1;
@@ -1546,10 +1595,22 @@ impl Sim {
let speed = if d.flight == Flight::Dive { spec.speed * 1.35 } else { spec.speed };
d.vel = new * speed;
d.pos += d.vel;
let sweep = ground.sweep(from, d.pos, d.age <= 3, now);
if let Some((_, p)) = sweep.hit() {
d.pos = p;
}
let at_target = d.pos.distance(d.target) < speed.max(1.5);
let in_ground = below.is_some_and(|g| d.pos.y <= g + 0.2);
let in_ground = sweep.hit().is_some() || ground.ground_hit(below, d.pos, 0.2, &sweep, now);
if at_target || in_ground || d.age > 8000 {
boom.push((d.pos, spec.warhead, spec.decoy));
// Into the land or a building short of its aim: a crash, not a hit.
if in_ground && d.pos.distance(d.target) >= speed.max(1.5) + 3.0 {
if !spec.decoy {
crashes.push((d.id, d.kind, d.pos, spec.warhead));
}
removed.push(d.id);
continue;
}
if !spec.decoy {
impacts.push((d.id, d.kind, d.pos, spec.warhead));
}
@@ -1580,6 +1641,17 @@ impl Sim {
for (drone, pos) in splits {
self.log(pos, record::EventKind::Split { drone });
}
for (drone, kind, pos, tnt) in crashes {
self.crashes += 1;
let power = match tnt {
0 => 0,
1 | 2 => 1,
3..=5 => 2,
6 | 7 => 3,
_ => 4,
};
self.log(pos, record::EventKind::Crash { drone, kind, power });
}
for (drone, kind, pos, tnt) in impacts {
if let Some(sector) = self.drones.iter().find(|x| x.id == drone).map(|x| x.sector as usize) {
self.learn(sector, false);
@@ -1594,6 +1666,8 @@ impl Sim {
let object = self.targets.iter().position(|t| t.distance(pos) < 16.0);
let sam = self.sams.iter().enumerate().find(|(_, s)| s.alive() && s.pos.distance(pos) < 12.0).map(|(i, s)| (s.kind, i));
self.log(pos, record::EventKind::Hit { drone, kind, power, object, sam });
let vel = self.drones.iter().find(|x| x.id == drone).map_or(DVec3::ZERO, |x| x.vel);
self.direct_hit(pos, vel, tnt, out);
}
for (pos, warhead, small) in boom {
self.explode(pos, warhead, small, ground, player, out);
@@ -2061,9 +2135,11 @@ impl Sim {
// ------------------------------------------------ interceptors
fn step_missiles(&mut self, out: &mut Out) {
fn step_missiles(&mut self, ground: &mut Ground, out: &mut Out) {
let now = self.tick;
let mut done: Vec<(u32, Option<u32>, bool, u32)> = Vec::new(); // (missile, target, kill, sam)
let (mut missed, mut expired, mut orphaned) = (0u32, 0u32, 0u32);
let mut blocked = 0u32;
let sams_fit: HashMap<u32, f64> = self.sams.iter().map(|s| (s.id, s.fitness())).collect();
for m in &mut self.missiles {
m.age += 1;
@@ -2091,13 +2167,16 @@ impl Sim {
let before = m.pos;
m.vel = new * speed;
m.pos += m.vel;
// The first block on the way this tick; a fuse that goes off earlier along the way still counts.
ground.ahead(m.pos, m.vel, 10.0, now);
let world = ground.sweep(before, m.pos, m.age <= 12, now).hit();
if let Some(t) = target {
// Closest approach this tick, both moving (fast targets cross a fuse in a tick).
let rel0 = (t.pos - t.vel) - before;
let rel1 = t.pos - m.pos;
let delta = rel1 - rel0;
let k = (-rel0.dot(delta) / delta.length_squared().max(1e-9)).clamp(0.0, 1.0);
if (rel0 + delta * k).length() < spec.fuse {
if (rel0 + delta * k).length() < spec.fuse && world.is_none_or(|(w, _)| k <= w) {
// A target faster than the interceptor is hard to hit.
let ratio = (spec.speed / t.vel.length().max(0.1)).min(1.0).powf(1.5);
let fit = sams_fit.get(&m.sam).copied().unwrap_or(1.0);
@@ -2107,6 +2186,13 @@ impl Sim {
continue;
}
}
if let Some((_, p)) = world {
// The land or a building is in the way: the interceptor is lost there.
m.pos = p;
blocked += 1;
done.push((m.id, None, false, m.sam));
continue;
}
if m.age > spec.life || (target.is_none() && m.age > 30) {
// Its target gone (shot down by another, or lost), or its life spent chasing one.
if target.is_none() {
@@ -2151,6 +2237,7 @@ impl Sim {
t.missed += missed;
t.expired += expired;
t.orphaned += orphaned;
t.blocked += blocked;
});
for (mid, target, kill, sam) in done {
if let Some(i) = self.missiles.iter().position(|m| m.id == mid) {
+7 -1
View File
@@ -39,6 +39,8 @@ pub enum EventKind {
SamLost { sam: u32, kind: SamKind, index: usize },
/// The Oreshnik's bus or a block splitting.
Split { drone: u32 },
/// A drone's warhead went off against the land or a building short of its aim.
Crash { drone: u32, kind: DroneKind, power: u32 },
}
#[derive(Clone, Copy, Debug)]
@@ -61,6 +63,8 @@ pub struct Tally {
pub tracks_lost: u32,
/// Times a system ran dry and began waiting for a reload truck.
pub ammo_waits: u32,
/// Interceptors lost against the land or a building on their way.
pub blocked: u32,
}
impl Tally {
@@ -71,6 +75,7 @@ impl Tally {
orphaned: self.orphaned.saturating_sub(start.orphaned),
tracks_lost: self.tracks_lost.saturating_sub(start.tracks_lost),
ammo_waits: self.ammo_waits.saturating_sub(start.ammo_waits),
blocked: self.blocked.saturating_sub(start.blocked),
}
}
/// The non-zero counts as a line, or nothing.
@@ -81,6 +86,7 @@ impl Tally {
(self.orphaned, "ракет остались без цели"),
(self.tracks_lost, "потерь трека"),
(self.ammo_waits, "ожиданий БК"),
(self.blocked, "ракет разбились о препятствие"),
]
.into_iter()
.filter(|p| p.0 > 0)
@@ -312,7 +318,7 @@ impl Sim {
}
let size = match e.kind {
EventKind::Kill { .. } => 3.5,
EventKind::Hit { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)],
EventKind::Hit { power, .. } | EventKind::Crash { power, .. } => [2.5, 4.5, 7.0, 10.0, 13.0][(power as usize).min(4)],
EventKind::SamLost { .. } => 8.0,
EventKind::Split { .. } => 2.0,
};
+279
View File
@@ -1,6 +1,7 @@
//! Regression tests for the issues filed on Gitea (each names its issue).
use super::*;
use super::raid::Grid;
use super::damage::MISS_RADIUS;
// #1: [bug] Запросы рельефа голодают: часть направлений РЛС никогда не получает высоты
#[test]
@@ -222,3 +223,281 @@ fn drive_arrives_without_overshoot() {
assert!(left.abs() < 1e-9 && vel == 0.0);
}
}
// #49: a direct hit by an armed drone destroys the system at once, not with a dent of its health
#[test]
fn armed_strike_on_a_system_destroys_it_at_once() {
let centre = DVec3::new(0.0, 70.0, 0.0);
let hull = 2.2 * SamKind::Patriot.spec().scale;
for kind in [DroneKind::Lancet, DroneKind::Shahed] {
let mut destroyed = 0;
for seed in 1..=40 {
let mut sim = Sim::new(seed);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, centre, 0.0));
let mut ground = Ground::default();
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, centre);
let mut out = Out::default();
let mut impact = None;
for _ in 0..400 {
sim.step(&mut ground, None, &mut out);
if let Some(b) = sim.blasts.first() {
impact = Some(b.at);
break;
}
if sim.drones.is_empty() {
break;
}
}
let at = impact.expect("the strike must go off");
let inside = DVec3::new(at.x, 0.0, at.z).distance(DVec3::new(centre.x, 0.0, centre.z)) <= hull && at.y < centre.y + 3.0;
let dead = !sim.sams[0].alive();
if inside {
assert!(dead, "{kind:?} seed {seed} struck the hull at {at:?} but the system lives");
}
destroyed += dead as u32;
}
assert!(destroyed >= 30, "{kind:?}: only {destroyed}/40 direct strikes destroyed the system");
}
}
// #49: a near miss is only splash, an unarmed body is no direct hit, the top of the hull counts
#[test]
fn near_miss_and_unarmed_body_are_not_direct_hits() {
let mut sim = Sim::new(1);
let id = sim.id();
sim.sams.push(Sam::new(id, SamKind::Patriot, DVec3::new(0.0, 70.0, 0.0), 0.0));
let down = DVec3::new(0.0, -2.0, 0.0);
sim.direct_hit(DVec3::new(6.0, 71.0, 0.0), down, 3, &mut Out::default());
assert!(sim.sams[0].alive(), "a near miss six blocks off destroyed the system");
sim.direct_hit(DVec3::new(0.0, 71.0, 0.0), down, 0, &mut Out::default());
assert!(sim.sams[0].alive(), "an unarmed body destroyed the system");
sim.direct_hit(DVec3::new(0.0, 73.0, 0.0), down, 1, &mut Out::default());
assert!(!sim.sams[0].alive(), "an armed warhead into the top of the hull did not destroy the system");
}
// #59: a strike misses at a small, kind-specific rate; its scatter is the kind's CEP (half land within it)
#[test]
fn strike_scatter_matches_each_kinds_cep_and_miss_rate() {
const N: u64 = 5000;
for kind in DroneKind::ALL {
let mut sim = Sim::new(7);
let (cep, gross) = kind.accuracy();
let sigma = cep / (2.0 * std::f64::consts::LN_2).sqrt();
let expected = (1.0 - gross) * (-MISS_RADIUS * MISS_RADIUS / (2.0 * sigma * sigma)).exp() + gross;
let (mut misses, mut within, mut farthest) = (0u64, 0u64, 0.0f64);
for _ in 0..N {
let e = sim.aim_error(kind);
assert_eq!(e.y, 0.0);
let r = e.length();
within += (r <= cep) as u64;
if r > MISS_RADIUS {
misses += 1;
farthest = farthest.max(r);
}
}
let rate = misses as f64 / N as f64;
let half = within as f64 / N as f64;
assert!((rate - expected).abs() < 0.015, "{kind:?}: miss rate {rate:.4}, expected {expected:.4}");
assert!((0.005..=0.20).contains(&rate), "{kind:?}: miss rate {rate:.4} is not a small share (the IRBM bus is the loosest)");
assert!((half - 0.5 * (1.0 - gross)).abs() < 0.03, "{kind:?}: {half:.3} of strikes within the CEP {cep}");
assert!(farthest < MISS_RADIUS + 4.0 * cep, "{kind:?}: a miss {farthest:.2} blocks out, far from the aim");
}
}
// #59: the drone flies to its own error point and bursts there: the miss lands near the aim, by the CEP
#[test]
fn strikes_burst_near_their_aim_within_the_kinds_cep() {
let aim = DVec3::new(0.0, 70.0, 0.0);
for kind in [DroneKind::Shahed, DroneKind::Lancet] {
let cep = kind.accuracy().0;
let mut misses = 0;
for seed in 1..=60 {
let mut sim = Sim::new(seed);
let mut ground = Ground::default();
sim.add_drone(kind, DVec3::new(0.0, 120.0, 0.0), DVec3::NEG_Y, aim);
let mut out = Out::default();
let mut impact = None;
for _ in 0..400 {
sim.step(&mut ground, None, &mut out);
if let Some(b) = sim.blasts.first() {
impact = Some(b.at);
break;
}
if sim.drones.is_empty() {
break;
}
}
let at = impact.expect("the strike must go off");
let off = DVec3::new(at.x - aim.x, 0.0, at.z - aim.z).length();
assert!(off < MISS_RADIUS + 4.0 * cep + 2.0, "{kind:?} seed {seed} burst {off:.2} blocks from its aim");
misses += (off > MISS_RADIUS) as u32;
}
assert!(misses <= 6, "{kind:?}: {misses}/60 strikes missed");
}
}
// ---- #42, #43: flights are swept against the blocks of the world (a synthetic world stands in for the game)
/// Blocks from a function: `Some(solid)`, or `None` where there is no chunk.
struct Blocks<F: Fn(i32, i32, i32) -> Option<bool>>(F);
impl<F: Fn(i32, i32, i32) -> Option<bool>> world::WorldSource for Blocks<F> {
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool> {
(self.0)(x, y, z)
}
}
/// Reads the sections a flight along `a` to `b` needs, as the game would answer them over a few ticks.
fn warm(ground: &mut Ground, src: &dyn world::WorldSource, a: DVec3, b: DVec3) {
for _ in 0..4 {
ground.world.sweep(a, b, false, 0);
let wanted = ground.world.take_wanted(0);
ground.world.serve(src, wanted, 0);
}
}
/// One tick of the drones with the game answering what was asked.
fn fly(sim: &mut Sim, ground: &mut Ground, src: &dyn world::WorldSource) {
sim.tick += 1;
sim.step_drones(ground, None, &mut Out::default());
let wanted = ground.world.take_wanted(sim.tick);
ground.world.serve(src, wanted, sim.tick);
}
// #42: a drone that crosses a one-block wall in a tick explodes at the wall, and that is no hit on its aim
#[test]
fn fast_drone_must_not_cross_a_one_block_wall() {
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
let mut sim = Sim::new(1);
sim.damage = false;
let mut ground = Ground::default();
sim.add_drone(DroneKind::Zircon, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(1000.0, 100.0, 0.0));
sim.drones[0].flight = Flight::Dive;
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
fly(&mut sim, &mut ground, &src);
assert!(sim.drones.is_empty(), "crossed the wall in one tick");
let at = sim.blasts.first().expect("no blast").at;
assert!((at.x - 3.0).abs() < 0.05 && (at.y - 100.0).abs() < 1.5, "burst at {at:?}, not at the wall face x=3");
assert_eq!((sim.crashes, sim.hits), (1, 0), "a wall in the way is a crash, not a hit on the aim");
assert!(sim.replays.iter().chain(sim.rec.iter()).all(|r| r.events.iter().all(|e| !matches!(e.kind, record::EventKind::Hit { .. }))));
}
// #42: a drone flying into a hill side explodes at the face of the hill
#[test]
fn drone_flying_into_a_hill_explodes_at_its_face() {
// Flat at 64 to x=50, then a slope of 1:1.
let height = |x: i32| 64 + (x - 50).max(0);
let src = Blocks(move |x, y, _| Some(y < height(x)));
for kind in [DroneKind::Shahed, DroneKind::Zircon] {
let mut sim = Sim::new(3);
sim.damage = false;
let mut ground = Ground::default();
sim.add_drone(kind, DVec3::new(0.0, 80.0, 0.0), DVec3::X, DVec3::new(600.0, 80.0, 0.0));
sim.drones[0].flight = Flight::Dive;
for _ in 0..300 {
if sim.drones.is_empty() {
break;
}
fly(&mut sim, &mut ground, &src);
}
assert!(sim.drones.is_empty(), "{kind:?} flew on");
let at = sim.blasts.first().expect("no blast").at;
// The slope meets y=80 at x=66.
assert!(at.x > 62.0 && at.x < 67.5, "{kind:?} burst at {at:?}, not at the hill face (x~66)");
assert_eq!(sim.crashes, 1, "{kind:?}");
}
}
// #42: an interceptor cannot pass through a wall to its target, and does not kill it from behind the wall
#[test]
fn missile_cannot_pass_through_a_wall() {
let src = Blocks(|x, y, z| Some(y < 64 || (x == 3 && (64..141).contains(&y) && z.abs() < 50)));
let mut sim = Sim::new(1);
let mut ground = Ground::default();
// The target stands still just behind the wall, inside the fuse's reach.
sim.add_drone(DroneKind::Shahed, DVec3::new(8.5, 100.0, 0.0), DVec3::ZERO, DVec3::new(0.0, 100.0, 900.0));
sim.drones[0].vel = DVec3::ZERO;
sim.drones[0].engaged = 1;
let (target, id) = (sim.drones[0].id, sim.id());
sim.missiles.push(Missile { id, kind: MissileKind::Pac3, pos: DVec3::new(0.0, 100.0, 0.0), vel: DVec3::X * 3.0, target, sam: 0, age: 20 });
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(30.0, 100.0, 0.0));
sim.tick = 1;
sim.step_missiles(&mut ground, &mut Out::default());
assert!(sim.missiles.is_empty(), "the missile is still flying");
assert_eq!(sim.kills, 0, "a kill through the wall");
assert!(sim.drones[0].alive(), "the target died behind the wall");
assert_eq!(sim.drones[0].engaged, 0, "the target stays claimed by a missile that is gone");
assert_eq!(sim.tally.blocked, 1);
assert!(sim.blasts.iter().all(|b| b.at.x < 3.5), "burst behind the wall");
}
// #42, #43: a bridge deck above the flight is no ground under it
#[test]
fn flight_under_a_bridge_is_not_a_ground_hit() {
let src = Blocks(|_, y, _| Some(y < 64 || y == 140));
let mut sim = Sim::new(1);
sim.damage = false;
let mut ground = Ground::default();
// The height map knows only the deck: 141 everywhere.
ground.answer((-5..400).map(|x| (x, 0, 141)).collect(), 0);
sim.add_drone(DroneKind::Shahed, DVec3::new(0.0, 100.0, 0.0), DVec3::X, DVec3::new(2000.0, 100.0, 0.0));
warm(&mut ground, &src, DVec3::new(0.0, 100.0, 0.0), DVec3::new(300.0, 100.0, 0.0));
for _ in 0..40 {
fly(&mut sim, &mut ground, &src);
}
assert_eq!(sim.drones.len(), 1, "the deck above was taken for the ground");
assert_eq!(sim.crashes, 0);
}
// #43: unknown chunks and spent budgets are never air; changes and another world void the cache
#[test]
fn unknown_is_not_clear_and_changes_invalidate() {
let mut ground = Ground::default();
let (a, b) = (DVec3::new(0.5, 70.5, 0.5), DVec3::new(30.5, 70.5, 0.5));
// Nothing read yet: the segment is unknown and the sections are asked for.
assert!(matches!(ground.world.sweep(a, b, false, 1), world::Sweep::Unknown { .. }));
let asked = ground.world.take_wanted(1);
assert!(asked.contains(&(0, 4, 0)), "{asked:?}");
// The game has no chunk there: still unknown, not air.
let nothing = Blocks(|_, _, _| None);
ground.world.serve(&nothing, asked, 2);
assert!(matches!(ground.world.sweep(a, b, false, 3), world::Sweep::Unknown { .. }));
// A wall appears in a chunk that now is loaded: found after it is read again.
let wall = Blocks(|x, _, _| Some(x == 20));
for now in [70, 71, 72] {
ground.world.sweep(a, b, false, now);
let w = ground.world.take_wanted(now);
ground.world.serve(&wall, w, now);
}
let hit = ground.world.sweep(a, b, false, 73);
assert!(matches!(hit, world::Sweep::Hit { block: [20, 70, 0], normal: [-1, 0, 0], .. }), "{hit:?}");
// The wall is broken: the game says which section changed, and the next sweep asks again.
let open = Blocks(|_, _, _| Some(false));
ground.world.changed(7, false, &[(1, 4, 0)]);
assert!(matches!(ground.world.sweep(a, b, false, 74), world::Sweep::Hit { .. }), "stale data is used until the answer");
let w = ground.world.take_wanted(74);
assert!(w.contains(&(1, 4, 0)), "{w:?}");
ground.world.serve(&open, w, 75);
assert_eq!(ground.world.sweep(a, b, false, 76), world::Sweep::Clear);
// Another world: everything goes.
ground.world.changed(8, false, &[]);
assert!(ground.world.is_empty());
}
// #43: the same wall and tunnel fixtures answer alike by sweep and by sections, and the cache is bounded
#[test]
fn wall_and_tunnel_fixtures_agree() {
let tunnel = Blocks(|x, y, z| Some(y < 60 || (x == 10 && !(y == 70 && z == 0))));
let mut ground = Ground::default();
for z in [0.5, 1.5] {
let (a, b) = (DVec3::new(0.5, 70.5, z), DVec3::new(30.5, 70.5, z));
warm(&mut ground, &tunnel, a, b);
let r = ground.world.sweep(a, b, false, 1);
if z == 0.5 {
assert_eq!(r, world::Sweep::Clear, "the tunnel is open");
} else {
assert!(matches!(r, world::Sweep::Hit { block: [10, 70, 1], .. }), "{r:?}");
}
}
assert!(ground.world.bytes() < 64 * 1024);
}
+399
View File
@@ -0,0 +1,399 @@
//! What the game's blocks are to the simulation (issues #42, #43): a cache of
//! 16-block sections answered by `render.sections` (a bit per block that
//! stops a projectile), a swept segment through it, and the bookkeeping that
//! keeps the questions few: the sections along a flight asked for ahead of
//! time, at most 64 a tick, nearest need first; changes of the world marking
//! sections stale; a bounded cache. A chunk the game doesn't have answers
//! `Unknown`, which is never the same as air: the callers fall back to the
//! height of the land there and the counters say how often.
use glam::DVec3;
use std::collections::{HashMap, HashSet};
/// A section: block coordinates divided by 16.
pub type Sec = (i32, i32, i32);
/// Sections asked in one tick (the game's budget).
pub const PER_TICK: usize = 64;
/// Sections kept; the least recently used go first beyond it.
const MAX_SECTIONS: usize = 12_000;
/// Ticks before an unanswered or unknown section is asked again.
const RETRY: u64 = 60;
/// Longest stretch of a flight asked for ahead, in blocks.
const AHEAD_MAX: f64 = 192.0;
/// Questions waiting: more are dropped (they are asked again when needed).
const QUEUE_MAX: usize = 512;
/// What a section holds, as the game answered.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Fill {
/// The game has no such chunk.
Unknown,
Empty,
Full,
/// 512 bytes, one bit per block, index `(y & 15) * 256 + (z & 15) * 16 + (x & 15)`.
Mixed(Box<[u8; 512]>),
}
/// An answer for a section, as the game gives it.
pub struct SectionAnswer {
pub sec: Sec,
pub fill: Fill,
}
struct Entry {
fill: Fill,
/// A block changed (or may have) since it was read: asked again when next used.
stale: bool,
/// The tick it was last used in a sweep.
used: u64,
/// The tick it was answered.
since: u64,
}
/// How a swept segment ended.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Sweep {
/// Every block along it is known and none stops it.
Clear,
/// The first solid block: where the segment meets it, which way the face looks, and the block.
Hit { t: f64, point: DVec3, normal: [i32; 3], block: [i32; 3] },
/// The walk reached blocks the game doesn't know (before any solid one), `t` of the way.
Unknown { t: f64 },
}
impl Sweep {
pub fn hit(&self) -> Option<(f64, DVec3)> {
match *self {
Sweep::Hit { t, point, .. } => Some((t, point)),
_ => None,
}
}
}
/// Counters for the report: questions, unknowns, memory, cost.
#[derive(Clone, Copy, Default, Debug)]
pub struct WorldStats {
pub sweeps: u64,
pub cells: u64,
pub hits: u64,
pub unknown_sweeps: u64,
pub sections_asked: u64,
pub sections_answered: u64,
pub sections_unknown: u64,
pub invalidated: u64,
pub evicted: u64,
pub world_changes: u64,
}
#[derive(Default)]
pub struct WorldCache {
secs: HashMap<Sec, Entry>,
urgent: Vec<Sec>,
ahead: Vec<Sec>,
queued: HashSet<Sec>,
/// Sections asked, and when.
asked: HashMap<Sec, u64>,
epoch: u32,
pub stats: WorldStats,
}
fn sec_of(x: i32, y: i32, z: i32) -> Sec {
(x >> 4, y >> 4, z >> 4)
}
impl WorldCache {
/// Memory the cached sections hold, about.
pub fn bytes(&self) -> usize {
self.secs
.values()
.map(|e| 48 + if matches!(e.fill, Fill::Mixed(_)) { 512 } else { 0 })
.sum()
}
pub fn len(&self) -> usize {
self.secs.len()
}
pub fn is_empty(&self) -> bool {
self.secs.is_empty()
}
/// One line for the report.
pub fn text(&self) -> String {
let s = &self.stats;
format!(
"мир: {} секций ({} КБ), запросов {}, неизвестно {}, лучей {} (ударов {}, неизв. {}), клеток {}",
self.len(),
self.bytes() / 1024,
s.sections_asked,
s.sections_unknown,
s.sweeps,
s.hits,
s.unknown_sweeps,
s.cells
)
}
/// The game's note of changes: another world voids everything; changed
/// sections (or too many to say) are read again when next used.
pub fn changed(&mut self, epoch: u32, overflow: bool, sections: &[Sec]) {
if epoch != self.epoch {
if self.epoch != 0 {
self.secs.clear();
self.urgent.clear();
self.ahead.clear();
self.queued.clear();
self.asked.clear();
}
self.epoch = epoch;
}
if overflow {
for e in self.secs.values_mut() {
e.stale = true;
}
self.stats.invalidated += self.secs.len() as u64;
}
for s in sections {
if let Some(e) = self.secs.get_mut(s) {
e.stale = true;
self.stats.invalidated += 1;
}
}
self.stats.world_changes += sections.len() as u64;
}
/// An explosion is about to change the land: the sections round it are read again.
pub fn touched(&mut self, at: DVec3, radius: f64) {
let lo = sec_of((at.x - radius).floor() as i32, (at.y - radius).floor() as i32, (at.z - radius).floor() as i32);
let hi = sec_of((at.x + radius).floor() as i32, (at.y + radius).floor() as i32, (at.z + radius).floor() as i32);
for x in lo.0..=hi.0 {
for y in lo.1..=hi.1 {
for z in lo.2..=hi.2 {
if let Some(e) = self.secs.get_mut(&(x, y, z)) {
e.stale = true;
}
}
}
}
}
pub fn answer(&mut self, answers: Vec<SectionAnswer>, now: u64) {
for a in answers {
self.asked.remove(&a.sec);
self.stats.sections_answered += 1;
if a.fill == Fill::Unknown {
self.stats.sections_unknown += 1;
}
self.secs.insert(a.sec, Entry { fill: a.fill, stale: false, used: now, since: now });
}
if self.secs.len() > MAX_SECTIONS {
// Whatever wasn't used for a minute goes; if that's not enough, the older half.
self.secs.retain(|_, e| now.saturating_sub(e.used) < 1200);
if self.secs.len() > MAX_SECTIONS {
let mut used: Vec<u64> = self.secs.values().map(|e| e.used).collect();
used.sort_unstable();
let cut = used[used.len() / 2];
self.secs.retain(|_, e| e.used >= cut);
}
self.stats.evicted += 1;
}
// Forget questions the game never answered, so they can be asked again.
if now % 64 == 0 {
self.asked.retain(|_, t| now.saturating_sub(*t) < RETRY);
}
}
/// The sections to ask this tick (at most [`PER_TICK`]): the ones a sweep
/// needed first, then those along flights.
pub fn take_wanted(&mut self, now: u64) -> Vec<Sec> {
let mut out = Vec::new();
for list in [&mut self.urgent, &mut self.ahead] {
let room = PER_TICK - out.len();
let n = room.min(list.len());
out.extend(list.drain(..n));
}
for s in &out {
self.queued.remove(s);
self.asked.insert(*s, now);
}
self.stats.sections_asked += out.len() as u64;
out
}
fn want(&mut self, s: Sec, urgent: bool, now: u64) {
if self.queued.contains(&s) || self.asked.get(&s).is_some_and(|&t| now.saturating_sub(t) < RETRY) {
return;
}
if self.urgent.len() + self.ahead.len() >= QUEUE_MAX {
return;
}
if urgent {
self.urgent.push(s);
} else {
self.ahead.push(s);
}
self.queued.insert(s);
}
/// Asks for the sections along a flight from `pos` at `vel` a tick, `ticks` ahead.
pub fn look_ahead(&mut self, pos: DVec3, vel: DVec3, ticks: f64, now: u64) {
let mut d = vel * ticks;
let len = d.length();
if len < 1e-6 {
return;
}
if len > AHEAD_MAX {
d *= AHEAD_MAX / len;
}
// A section at a time along the segment (a section is 16 wide: half-section steps meet each).
let steps = (d.length() / 8.0).ceil().max(1.0) as i32;
let mut last = None;
for i in 0..=steps {
let p = pos + d * (i as f64 / steps as f64);
let s = sec_of(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
if last == Some(s) {
continue;
}
last = Some(s);
self.touch(s, false, now);
}
}
/// Touches a section the way a sweep or a look needs it: used now, and
/// asked again when stale or (after a while) unknown.
fn touch(&mut self, s: Sec, urgent: bool, now: u64) -> bool {
let Some(e) = self.secs.get_mut(&s) else {
self.want(s, urgent, now);
return false;
};
e.used = now;
let refresh = e.stale || (e.fill == Fill::Unknown && now.saturating_sub(e.since) >= RETRY);
if refresh {
self.want(s, urgent, now);
}
true
}
/// Whether the block at `(x, y, z)` stops a projectile: `None` where the
/// game hasn't said. `last` is the section just touched.
fn solid(&mut self, last: &mut Option<Sec>, x: i32, y: i32, z: i32, now: u64) -> Option<bool> {
let s = sec_of(x, y, z);
if *last != Some(s) {
*last = Some(s);
self.touch(s, true, now);
}
match &self.secs.get(&s)?.fill {
Fill::Unknown => None,
Fill::Empty => Some(false),
Fill::Full => Some(true),
Fill::Mixed(bits) => {
let i = ((y & 15) << 8 | (z & 15) << 4 | (x & 15)) as usize;
Some(bits[i >> 3] >> (i & 7) & 1 != 0)
}
}
}
/// Whether the block is known to hold no solid block, with the sky above it
/// in view: used to tell a fluid surface from a roof.
pub fn is_known_open(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool {
self.solid(&mut None, x, y, z, now) == Some(false)
}
/// Whether the block is known to be solid.
pub fn is_known_solid(&mut self, x: i32, y: i32, z: i32, now: u64) -> bool {
self.solid(&mut None, x, y, z, now) == Some(true)
}
/// The first solid block along `a` to `b`, by walking the blocks it
/// crosses; `skip_start` ignores the block it starts in (a launch from a
/// pad that is part of the land).
pub fn sweep(&mut self, a: DVec3, b: DVec3, skip_start: bool, now: u64) -> Sweep {
self.stats.sweeps += 1;
let d = b - a;
let mut pos = [a.x.floor() as i32, a.y.floor() as i32, a.z.floor() as i32];
let dv = [d.x, d.y, d.z];
let av = [a.x, a.y, a.z];
let step: [i32; 3] = dv.map(|v| if v > 0.0 { 1 } else { -1 });
let mut t_max = [f64::INFINITY; 3];
let mut t_delta = [f64::INFINITY; 3];
for i in 0..3 {
if dv[i] != 0.0 {
let edge = f64::from(pos[i]) + if dv[i] > 0.0 { 1.0 } else { 0.0 };
t_max[i] = (edge - av[i]) / dv[i];
t_delta[i] = (1.0 / dv[i]).abs();
}
}
let mut last: Option<Sec> = None;
let mut face = [0i32; 3];
let mut t = 0.0f64;
let mut first = true;
for _ in 0..4096 {
self.stats.cells += 1;
match self.solid(&mut last, pos[0], pos[1], pos[2], now) {
None => {
self.stats.unknown_sweeps += 1;
return Sweep::Unknown { t };
}
Some(true) if !(first && skip_start) => {
self.stats.hits += 1;
return Sweep::Hit { t, point: a + d * t, normal: face, block: pos };
}
_ => {}
}
first = false;
let axis = if t_max[0] < t_max[1] {
if t_max[0] < t_max[2] { 0 } else { 2 }
} else if t_max[1] < t_max[2] {
1
} else {
2
};
t = t_max[axis];
if t > 1.0 {
return Sweep::Clear;
}
t_max[axis] += t_delta[axis];
pos[axis] += step[axis];
face = [0; 3];
face[axis] = -step[axis];
}
self.stats.unknown_sweeps += 1;
Sweep::Unknown { t }
}
}
/// Where the game's blocks come from in tests and the native runner: any
/// world that can say what a block is.
pub trait WorldSource {
/// Whether the block stops a projectile; `None` where there is no chunk.
fn solid(&self, x: i32, y: i32, z: i32) -> Option<bool>;
}
/// A [`WorldSource`] as the game would answer `render.query-sections` with it.
pub fn section_of(source: &dyn WorldSource, s: Sec) -> Fill {
let mut bits = Box::new([0u8; 512]);
let (mut set, mut known) = (0u32, 0u32);
for i in 0..4096usize {
let (x, y, z) = (s.0 * 16 + (i & 15) as i32, s.1 * 16 + (i >> 8) as i32, s.2 * 16 + ((i >> 4) & 15) as i32);
match source.solid(x, y, z) {
Some(true) => {
bits[i >> 3] |= 1 << (i & 7);
set += 1;
known += 1;
}
Some(false) => known += 1,
None => {}
}
}
match (known, set) {
(0, _) => Fill::Unknown,
(_, 0) if known == 4096 => Fill::Empty,
(_, 4096) => Fill::Full,
_ => Fill::Mixed(bits),
}
}
impl WorldCache {
/// Answers the questions of the last tick from a synthetic world, as the game does one tick later.
pub fn serve(&mut self, source: &dyn WorldSource, wanted: Vec<Sec>, now: u64) {
let answers = wanted.into_iter().map(|sec| SectionAnswer { sec, fill: section_of(source, sec) }).collect();
self.answer(answers, now);
}
}
+18 -2
View File
@@ -20,6 +20,7 @@ Horizon действует правило clean-room. Исключение — P
| Что | Где |
|---|---|
| Весь HD-конвейер: цели, проходы, WGSL-шейдеры (строками), тайлы света, AO, тени, bloom, tonemap, TAA | `horizon/crates/horizon-client/src/gfx/hd.rs` |
| Объёмные облака (рэймарч слоя, тени облаков на землю) и объёмный туман (лучи света через тени каскадов), их цели и проходы; CPU-шум (2D, 3D Worley) | `horizon/crates/horizon-client/src/gfx/hd_volumes.rs`, `hd_noise.rs` |
| Атмосфера: сборка текстуры неба (WGSL), чтение неба/дымки/тона света в шейдерах, цвет солнца (`sun_transmittance`) | `horizon/crates/horizon-client/src/gfx/hd_sky.rs` (+ `THIRD_PARTY_PHOTON.md`) |
| Встраивание в кадр: G-буфер-проход, каскады теней, вызовы `hd.light` / `hd.finish`, джиттер, тайминги | `horizon/crates/horizon-client/src/gfx.rs` (`Renderer::render`, ищи `hd_on`, `AirDef fork:`) |
| G-буфер террейна: `fs_gbuffer`, `fs_shadow` | `horizon/crates/horizon-client/src/terrain.wgsl` |
@@ -62,6 +63,7 @@ cd .. && rm -f horizon-patches/*.patch && git -C horizon format-patch -q $(cat H
землёй решают потребители). Пайплайны `gbuffer_water` (секции) и `flat.water` (flat-путь).
3a. **GTAO** (полразрешения, `Rg16Float`: окклюзия, дистанция) + depth-aware блюр 5×5. 3 среза × 5 шагов, радиус
1.8 блока, шум IGN по кадру. Сила — `HdFrame.ambient.w` (`HORIZON_HD_AO=<число>`, 0 = выкл).
3b. **Облака и туман** (`hd.volumes`, до освещения, после прохода неба): `hd cloud shadows` (карта 256² оптической толщины облаков по ходу солнца, ±1200 блоков вокруг камеры), `hd clouds` (половина разрешения мира, `Rgba16Float`: свет + пропускание; рэймарч слоя 192.33…264 блока, 8–26 шагов с джиттером IGN, свет — 3 шага с удвоением, небо — 1, 4 октавы многократного рассеяния Photon; история ping-pong с репроекцией по направлению и сдвигу камеры, вес до 0.9, отбрасывается при расхождении) и `hd fog` (половина разрешения: 18 шагов от камеры до глубины мира, не дальше 192 блоков, плотность — высотная дымка + утренний туман у земли + дождь + шум, свет — солнце × тень каскадов × фаза Photon + свет неба; вторая цель — длина луча для апсэмплинга). Ванильные облака при этом не рисуются (`hd_volumetric_clouds` в `gfx.rs`). `fs_light` накладывает облака на небо (`c·T + S`), туман на всё (`fog_at`: 4 текселя с весом по близости длины луча), а тень облаков (`cloud_shadow`) умножает прямой солнечный свет. `HORIZON_HD_CLOUDS=0` / `HORIZON_HD_VOLFOG=0` выключают (облака тогда ванильные, лучи — старые 14 шагов).
4. **Освещение** `fs_light` → `lit` (HDR): пиксель неба (глубина мира 0, Оверворлд, без env-тумана) — это небо атмосферы + то, что мир нарисовал поверх ванильного неба (`max(scene − vanilla_sky(view), 0)`: спрайты луны, звёзды; квадрат ванильного солнца вырезается вокруг направления солнца и заменяется круглым диском с гало); облака (глубина > 0, нет в G-буфере, y > 8, дальше 48 блоков) на закате/рассвете окрашиваются цветом солнца. Свет неба: ванильный `lightmap` × подкраска цветом неба (зенит сверху, горизонт сбоку; яркость 1) × форма по нормали (вверх 1.1, вниз 0.66), только там, где есть небесный свет. Тени: ближний каскад — PCSS-lite (поиск блокировщиков 8 выборок, ширина полутени от расстояния до блокировщика, 12 выборок спирали Фогеля, повёрнутых IGN; сглаживает TAA), дальний — 3×3. Туман: цвет — небо над горизонтом в направлении пикселя (`haze_colour`) + воздушная перспектива `1 − exp(−d/700)` × 0.5. Далее прежнее: пиксель — вода, если `water_depth >= g` и перед водой в мировой глубине ничего нет
(`d - wd <= 0.2%`); тогда освещается **дно** по данным G-буфера (одна выборка тени, без AO), а в альфу `lit` пишется 0.5 (признак воды). `max(небо·ambient.x, блоки·ambient.y)·AO + солнце·N·L·тень + точечные
источники` × albedo + эмиссия; затем ванильный туман заново, объёмные лучи (14 шагов, Henyey-Greenstein g=0.6),
@@ -109,10 +111,12 @@ Bind groups кэшируются и пересоздаются только пр
| 13 | AO |
| 14 | глубина воды (`water_depth`) |
| 15 | текстура неба (`sky_lut`) |
| 16, 17 | объёмный туман (свет+пропускание) и длина его луча |
| 18, 19 | объёмные облака, карта теней облаков |
`HdFrame` (Rust и WGSL держать в синхроне!): `inverse_view_proj`, `resolution` (w, h, 1/w, 1/h), `params` (число
источников, экспозиция, bloom, счётчик кадров), `cascades[2]`, `sun` (направление, сила), `sun_color` (rgb, ночь),
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо), `sky_sun` (xyz: на само солнце, w: 1 — небо рисует атмосфера), `sky_extra` (x: конец ванильного тумана неба, z: пасмурность от дождя 0..0.85), `sunrise` (ванильная полоса восхода/заката — вычитается из мира).
`fog_color`, `fog`, `ambient` (тень неба, блоки, плотность воздуха, AO), `shadow` (вкл, R0, R1, тайлов в строке), `prev_view_proj`, `taa`, `taa_params`, `view_proj` (с джиттером, для лучей воды), `water` (x: эффект идёт, y: секунды волн mod 600, zw: камера x, z mod 256), `sky_color` (w: есть небо), `sky_sun` (xyz: на само солнце, w: 1 — небо рисует атмосфера), `sky_extra` (x: конец ванильного тумана неба, z: пасмурность от дождя 0..0.85), `sunrise` (ванильная полоса восхода/заката — вычитается из мира), `clouds` (низ слоя относительно камеры, толщина, вкл, секунды ветра), `cloud_cam` (x, z камеры по модулю 65536, добавка покрытия от дождя, история облаков годна), `cloud_dir` / `cloud_sun` (откуда и каким цветом светят облаку: солнце до чуть ниже горизонта, ночью луна), `vfog` (туман вкл, высота камеры, утренний туман).
Композит воды (`WATER`, group 0): 0 `HdFrame`, 1 `lit`, 2 глубина G-буфера, 3 цвет воды, 4 нормаль воды, 5 глубина воды, 6–7 тени, 8 лайтмап, 9 сэмплер, 10 текстура неба. Проход неба (`SKY_PASS`, group 0): только `HdFrame`.
@@ -172,6 +176,9 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
| Вода: проход `fs_water`, преломление, SSR, Френель, блик (`HORIZON_HD_WATER=0` — выкл) | готово | патч 19 (+20: без ванильной текстуры на поверхности, цвет темнее и глубже) |
| Вода: светлее, густая мелкая рябь (26 волн), блик a=0.07 (мерцание на реке ≈1.4% пикселей >20/255) | готово | патч 21 |
| Небо и атмосфера (Рэлей/Ми/озон, текстура неба, воздушная перспектива, цвет солнца из пропускания, голубоватый ambient по нормали, PCSS-lite ближнего каскада, диск солнца, цвет облаков на закате; порт констант и Chapman из Photon, MIT) | готово | патч 22 |
| Закат как у Photon: атмосфера в Rec. 2020 (коэффициенты и матрицы Photon), фаза Клейна-Нишины, тинт и усиление солнца на закате/рассвете/синем часе (`get_sun_tint`), насыщенность неба у низкого солнца | готово | патч 23 |
| Объёмные облака (рэймарч, мультирассеяние Photon, тени на землю, TAA-история, ванильные скрыты) | готово | патч 24 |
| Объёмный туман (лучи через тени каскадов, высотная дымка, утренний туман, полразрешения) | готово | патч 25 |
| API моделей по хэндлам (#30, уровень API 11: `model-id`, `model-generation`, `set-models-by-id`) | готово | патч 17 |
## План (по порядку) и ТЗ
@@ -190,7 +197,7 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
3. **Небо и атмосфера** — СДЕЛАНО (патч 22, см. «Небо и свет» ниже). Исходное ТЗ: рассеяние Рэлея/Ми (предвычисленная LUT пропускания и небесного цвета в маленькой
текстуре), солнце/луна дисками, звёзды из ванили; туман и лучи берут цвет из той же модели. Учесть дождь
(`weather`), закат, Незер/Энд (там — ванильное небо).
4. **Объёмные облака** (рэймарч слоя с шумом) + их тени на земле (в карту/маску солнца).
4. **Объёмные облака** — СДЕЛАНО (патч 24, см. «Закат, облака и туман»). Осталось: перистые/высококучевые слои Photon, облака в отражении воды.
5. **Блики и материалы**: specular солнца для металла техники (маска по оверлею/альбедо или атрибут модели из
мода), мокрые поверхности и лужи в дождь.
6. **PCSS** (мягкость тени по расстоянию до тенеотбрасывателя) и цветные тени через стекло/листву.
@@ -241,3 +248,12 @@ TAAU (2560×1440, GPU avg кадр, сцена Polygon): scale 1.0 — 18.1 мс
(`light_of`: множитель (1, 0.93, 0.78)). Проверять баланс на `Polygon` в полдень (`time set 6000`) — белое должно
оставаться белым с тёплым оттенком.
## Закат, облака и туман (патчи 23–25): что знать преемнику
- **Закат (23).** Небо марширует в Rec. 2020 (коэффициенты Photon сдвинуты матрицей `rec709_to_rec2020`, результат возвращается в 709): это убрало жёлто-зелёный горизонт. Добавлены фаза Клейна-Нишины для Ми и деполяризованная Рэлея, `sun_light_tint` (розоватый тинт и подъём яркости на закате, фиолетовый на синем часе, из `get_sun_tint`), сильнее насыщенность у низкого солнца (`sky_show`). Цвет прямого света (`light_of`) умножен на тот же тинт. Сверка: 12000/12500/13000 (запад) и 23000/23500 (восток) — оранжевый/розовый/пурпурный; полдень Polygon (6000) прежний.
- **Облака (24).** Слой 192.33…264.33 блока (`CLOUD_BASE/CLOUD_THICKNESS`), ветер 3 блока/с (`CLOUD_WIND`), шум — текстуры, сгенерированные на CPU при создании HD (`hd_noise.rs`: 2D 256² покрытие/форма, 3D 64³ Worley). Плотность — по `clouds_cumulus_density` Photon (порог покрытия × форма, «яйцо» по высоте, эрозия Worley, резкий верх), рассеяние — `clouds_cumulus_scattering` (октавы, пудра, фазы). Цвет света — `cloud_light` (Rust): пропускание атмосферы на высоте солнца; солнце светит облакам до −0.2 по высоте, дальше луна. Воздушная перспектива `exp(−d/3600)` к цвету дымки. История сбрасывается при resize, телепорте и без TAA. Опция ванильных облаков «выкл» → `frame.clouds` пуст → нет ни облаков, ни теней. Тень: `cloud_shadow` в `fs_light` (карта оптической толщины; гаснет при солнце ниже ~0.1–0.25 по высоте).
- **Туман (25).** `fs_fog`: 18 шагов, плотность `fog_sigma` (высотная дымка от y=62, масштаб 42; утренний туман — пока солнце на «утренней» стороне и низко; пятна из 3D-шума; дождь; затухание ниже y≈28–58, чтобы не светить в пещерах), свет — Photon-смесь двух HG, «вечернее свечение» ×1…3. Старые 14 шагов лучей остаются при `HORIZON_HD_VOLFOG=0`; ореолы вокруг источников мода — всегда.
- **Тайминги.** Метки Metal (`HORIZON_SHADER_PACK_TIMING=1`, Hills, 2560×1440): `hd clouds` ≈ 6 мс (завышено: метка ловит ожидание соседних проходов), `hd cloud shadows` 0.2–3 (шум), `hd fog` ≈ 1.3. По среднему кадру GPU «оба выкл → оба вкл»: +0.2…3.7 мс (замеры шумные: рядом шла чужая игра на том же GPU). Как удешевить: у облаков — длина шага (14) и потолок шагов (26) в `fs_cloud`, 3 шага света, 4 октавы, облака в четверть разрешения; у тумана — `FOG_STEPS`.
- **Разработка шейдеров.** `HORIZON_HD_SHADER_DIR=<папка>`: каждый HD-шейдер читается из `<папка>/<метка>.wgsl` (записывается из встроенного, если файла нет) — правки видны после перезапуска игры без пересборки; готовое переносится в константы вручную.
- **Слабые места.** Облака не отражаются в воде (SSR берёт небо без облаков); не закрывают далёкую землю выше слоя (только небо); карта теней облаков ±1200 блоков и почти не работает при солнце ниже ~10°; туман не знает рельефа (только высоту над y=62); нет перистых облаков; на быстром повороте камеры история облаков отбрасывается (кратковременный шум).
@@ -1,7 +1,7 @@
From 3de7a6875140a107e28563cc44489b55f5794517 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:37:45 +0300
Subject: [PATCH 01/21] Mod API (AirDef fork): world models, client particles,
Subject: [PATCH 01/24] Mod API (AirDef fork): world models, client particles,
sounds, ground heights
render.set-models draws block models with an interpolated pose per id;
@@ -1,7 +1,7 @@
From a6d64e6415bf618539af464e521091e72b72d72f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 12:39:45 +0300
Subject: [PATCH 02/21] cargo-horizon-mod: report particles and sounds
Subject: [PATCH 02/24] cargo-horizon-mod: report particles and sounds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 13:33:10 +0300
Subject: [PATCH 03/21] Mod API (AirDef fork): full-screen post pass for mods
Subject: [PATCH 03/24] Mod API (AirDef fork): full-screen post pass for mods
render.set-post compiles a mod's WGSL after a prelude (camera matrices,
time, 256 vec4 of params, the world's colour and depth) and runs it after
@@ -1,7 +1,7 @@
From 2b137dbbedd540daeebc7f5e387847aeb56a1355 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:09:30 +0300
Subject: [PATCH 04/21] Mod models: draw far models as scaled impostors past
Subject: [PATCH 04/24] Mod models: draw far models as scaled impostors past
the far plane, cull at 4096
---
@@ -1,7 +1,7 @@
From c8ebca6e0af68630e65211844b34d25c5b985c03 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 22:31:00 +0300
Subject: [PATCH 05/21] Mod cameras: render.set-views draws the world from
Subject: [PATCH 05/24] Mod cameras: render.set-views draws the world from
other points into pictures that HUD view shapes show (API level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8d2261fc4f8072c32e454139cfea7a8c0cd9d664 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:08:21 +0300
Subject: [PATCH 06/21] Mods: more fuel per tick and per event (AirDef plans
Subject: [PATCH 06/24] Mods: more fuel per tick and per event (AirDef plans
raids)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 6c548de34def8e4a624d734243418090798bd16a Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Wed, 28 Oct 2026 23:25:29 +0300
Subject: [PATCH 07/21] Mod cameras up to 1920x1080 (full-screen replays)
Subject: [PATCH 07/24] Mod cameras up to 1920x1080 (full-screen replays)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
@@ -1,7 +1,7 @@
From 9b9e93269d67401cc620567a884d5ce97f68d76e Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:15:11 +0300
Subject: [PATCH 08/21] Mod cameras: distant terrain tiles, fog measured from
Subject: [PATCH 08/24] Mod cameras: distant terrain tiles, fog measured from
the camera, no impostors while one is shown
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 065d9fa02966238a5bd9ff460392a1a809db0b11 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 11:25:45 +0300
Subject: [PATCH 09/21] Mods: game.world-id, so mods keep state per world (API
Subject: [PATCH 09/24] Mods: game.world-id, so mods keep state per world (API
level 9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1bf28135fd9a31f837323dc3a8d81ed73343d816 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 12:01:08 +0300
Subject: [PATCH 10/21] cargo-horizon-mod: trial runs behind feature full, so a
Subject: [PATCH 10/24] cargo-horizon-mod: trial runs behind feature full, so a
packing-only tool skips wasmtime
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 0a56f5616df4699588cbcc529d4720661baa810f Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:18:43 +0300
Subject: [PATCH 11/21] HD lighting: G-buffer pass, per-pixel mod point lights
Subject: [PATCH 11/24] HD lighting: G-buffer pass, per-pixel mod point lights
in HDR, bloom and tone curve; render.set-lights and render.set-lighting (API
level 10)
@@ -1,7 +1,7 @@
From 9ebf0031fe9ba6b7f02afb26e4fd3a1ba4ddb0d0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 13:59:43 +0300
Subject: [PATCH 12/21] HD lighting stage 2: sun and moon shadow cascades, the
Subject: [PATCH 12/24] HD lighting stage 2: sun and moon shadow cascades, the
world relit (sun by face angle and shadow, sky and block light through the
lightmap), light shafts and halos, fog over it again, a light colour grade
@@ -1,7 +1,7 @@
From 7a5a38f68923aec62f10bfa0df50b436474ed612 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:19:53 +0300
Subject: [PATCH 13/21] HD: bind groups cached until a resize or a new source,
Subject: [PATCH 13/24] HD: bind groups cached until a resize or a new source,
shadow passes draw only the entities that cast shadows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 8020811e57d4b5a9ea1cd72d30ce859dc5095d35 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:25:44 +0300
Subject: [PATCH 14/21] HD: lights culled per 16x16 tile (surface and halo
Subject: [PATCH 14/24] HD: lights culled per 16x16 tile (surface and halo
lists built on the CPU), a naga test for the lighting shaders
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 72d3fddfea329e578162656151350f0aa2efd323 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 16:43:36 +0300
Subject: [PATCH 15/21] HD: GPU time per pass in the F3 timing list,
Subject: [PATCH 15/24] HD: GPU time per pass in the F3 timing list,
half-resolution GTAO with a depth-aware blur
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 1fd83a821bf270879d8ece5aa583f200ed920d89 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 16/21] HD: shadows draw only sun-facing quads and skip buried
Subject: [PATCH 16/24] HD: shadows draw only sun-facing quads and skip buried
sections, the far cascade every second frame; TAA (Halton jitter,
reprojection by depth, YCoCg variance clipping) before bloom
@@ -1,7 +1,7 @@
From 1502d359d599228fe0f44ce6b99a3fded2ac5ea0 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:02:02 +0300
Subject: [PATCH 17/21] Mods: model handles (render.model-id, model-generation,
Subject: [PATCH 17/24] Mods: model handles (render.model-id, model-generation,
set-models-by-id; API level 11) so mods send no model names each tick;
clear-post and set-lighting documented
@@ -1,7 +1,7 @@
From 0f9d99b8ad3e4b72e621d84c2396cf1d3d2b7a33 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:19:57 +0300
Subject: [PATCH 18/21] HD: temporal upscaling (HORIZON_HD_SCALE: world,
Subject: [PATCH 18/24] HD: temporal upscaling (HORIZON_HD_SCALE: world,
G-buffer and lighting at a lower size, rebuilt to the window in the TAA
pass); fix flickering shadows near the player (the near cascade lost its
sections on frames the far one was held, the held far matrix ignored the
@@ -1,7 +1,7 @@
From 38250c4392e27d5700842ca6ee47291dd1831b72 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:47:52 +0300
Subject: [PATCH 19/21] HD water: its own pass (colour, normal, depth), the
Subject: [PATCH 19/24] HD water: its own pass (colour, normal, depth), the
floor under it lit, a composite with world-space waves, refraction with depth
absorption in the biome colour, screen-space reflections, Fresnel and a
shadowed sun glint (HORIZON_HD_WATER=0 to switch off)
@@ -1,7 +1,7 @@
From dbc6f8a6e9c2eeabe1de15f1794013c90be7faa5 Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 17:56:04 +0300
Subject: [PATCH 20/21] HD water: no vanilla texture on the surface (the biome
Subject: [PATCH 20/24] HD water: no vanilla texture on the surface (the biome
colour only), a darker, deeper colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,7 +1,7 @@
From 13e74537dd6e63482e3613e0e416de1158944dfe Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 18:38:19 +0300
Subject: [PATCH 21/21] HD sky and light: a physical atmosphere (Rayleigh, Mie,
Subject: [PATCH 21/24] HD sky and light: a physical atmosphere (Rayleigh, Mie,
ozone; constants and transmittance after Photon, MIT) for the sky, haze, fog
and water reflections; warmer sunlight from the atmosphere's transmittance, a
gentle sky-coloured ambient, soft PCSS-lite shadows near the player, a haze
@@ -0,0 +1,42 @@
From 6277e051a89c2537570982f6ef275887193c05cf Mon Sep 17 00:00:00 2001
From: chexit <chexit@duck.com>
Date: Thu, 29 Oct 2026 19:02:16 +0300
Subject: [PATCH 22/24] HD water: a darker blue, a little clearer, ripples a
little higher
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
crates/horizon-client/src/gfx/hd.rs | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs
index 031e9f2de..f078d423c 100644
--- a/crates/horizon-client/src/gfx/hd.rs
+++ b/crates/horizon-client/src/gfx/hd.rs
@@ -558,7 +558,8 @@ fn wave_gradient(xz: vec2<f32>, footprint: f32) -> vec2<f32> {
let phase = dot(k, xz) - omega * frame.water.y + w.w;
grad += (k / len) * (w.z * fade * cos(phase));
}
- return grad;
+ // A little higher than the table's slopes.
+ return grad * 1.25;
}
fn cascade_at(p: vec3<f32>, c: i32) -> vec3<f32> {
@@ -713,10 +714,10 @@ fn fs_water(in: FullOut) -> @location(0) vec4<f32> {
// A deep, dark water: the biome's colour darkened and a little greyed, so it reads as
// depth rather than as bright paint.
let biome = clamp(tint.rgb, vec3<f32>(0.03), vec3<f32>(1.0));
- let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.92, 1.0, 1.0);
+ let colour = mix(vec3<f32>(dot(biome, vec3<f32>(0.333))), biome, 0.85) * vec3<f32>(0.78, 0.86, 0.95);
// The colours the water takes up fastest are the ones it has the least of.
- let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.8 * (thickness + 0.3));
- let refracted = behind * absorb + colour * volume_light * mix(0.85, 0.4, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
+ let absorb = exp(-(vec3<f32>(1.0) - colour) * 0.6 * (thickness + 0.3));
+ let refracted = behind * absorb + colour * volume_light * mix(0.65, 0.3, frame.sun_color.a) * (vec3<f32>(1.0) - absorb);
// Reflection: the screen where the ray stays on it, the sky where it leaves it or misses.
var r = reflect(-v, n);
--
2.54.0 (Apple Git-157)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+589
View File
@@ -0,0 +1,589 @@
#!/usr/bin/env python3
"""Графический полигон HD (#44): фиксированные сцены, сравнение кадров, мерцание и GPU-бюджет.
python3 tools/hd_bench.py --list # сцены и их параметры
python3 tools/hd_bench.py # все сцены -> run-home-bench/out/<время>/
python3 tools/hd_bench.py --only polygon_noon # одна сцена (годится и при занятой игре)
python3 tools/hd_bench.py --env HORIZON_HD_TAA=0 --out run-home-bench/out/taa-off
python3 tools/hd_bench.py --baseline run-home-bench/out/before --out run-home-bench/out/after
Каждый прогон: свежая копия миров Polygon и Hills из run-home/saves в run-home-bench/home, мод airdef,
игра офскрин на сокете run-home-bench/c.sock (одна за раз; чужой игре run-home/ctl.sock не мешаем).
Для каждой сцены: время суток, ясная погода, телепорт, пауза на устаканивание, кадр, ещё один кадр
(мерцание), `bench 4` (GPU avg, мс) и средние проходы из run-home-bench/c.log при HORIZON_SHADER_PACK_TIMING=1.
Итог: report.md и results.json в папке вывода.
Коды выхода: 0 — всё в порогах и бюджете; 1 — есть превышение; 2 — ошибка запуска или аргументов.
Только стандартная библиотека; при установленном Pillow сравнение быстрее.
"""
import argparse
import datetime
import json
import os
import re
import shutil
import struct
import subprocess
import sys
import time
import zlib
from dataclasses import asdict, dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BENCH = ROOT / "run-home-bench"
SOCK = BENCH / "c.sock" # короткий путь: лимит Unix-сокета около 104 байт
LOG = SOCK.with_suffix(".log") # лог игры рядом с сокетом (как ctl.log в run-home)
HOME = BENCH / "home" # HORIZON_HOME одного прогона, пересоздаётся каждый раз
CTL = ROOT / "horizon" / "target" / "fast" / "horizon-ctl"
SAVES = ROOT / "run-home" / "saves"
OTHER_SOCK = "run-home/ctl.sock" # чужая игра (другой агент): только читаем, не трогаем
DIFF_LEVEL = 20 # порог «заметной» разницы по каналу, из 255
DIFF_GAIN = 6 # усиление разницы в diff-картинках
TIMING_RE = re.compile(r"shader pack GPU time per pass .*?: (.*) = ([\d.]+)\s*$")
@dataclass
class Shot:
name: str
world: str
time: int # время суток: 0 рассвет, 1000 утро, 6000 полдень, 12000 закат, 18000 полночь
pose: tuple # x, y, z, yaw, pitch (телепорт `tp @s`)
note: str
# Позы проверены в игре: полигон с техникой, река Hills (-26.5 72 0.5, взгляд на реку и холм), лес Hills на восток
# перед сравнением и поправь здесь, если кадр не то, что написано в note.
SHOTS = [
Shot("polygon_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 135.0, 25.0),
"полигон с техникой, полдень, белые материалы"),
Shot("hills_river_noon", "Hills", 6000, (-26.5, 72.0, 0.5, -150.0, 8.0),
"река на Hills, полдень: вода, преломление, блики"),
Shot("hills_river_sunset", "Hills", 12000, (-26.5, 72.0, 0.5, -150.0, 8.0),
"река на Hills, закат: тинт неба и воды"),
Shot("hills_river_night", "Hills", 18000, (-26.5, 72.0, 0.5, -150.0, 8.0),
"река на Hills, ночь: луна, туман"),
Shot("forest_morning_sun", "Hills", 1000, (-26.5, 71.0, 0.5, -90.0, 3.0),
"лес на Hills, утро, взгляд на восток (солнце ~15 градусов над горизонтом)"),
Shot("sky_noon", "Polygon", 6000, (40.5, 76.0, 40.5, 0.0, -40.0),
"небо и облака, полдень, взгляд вверх"),
]
class BenchError(RuntimeError):
pass
def die(msg, code=2):
print(f"hd_bench: {msg}", file=sys.stderr)
sys.exit(code)
# --- процессы и игра -------------------------------------------------------------------------
def pgrep(pattern):
return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0
def ctl_ok(*args):
r = subprocess.run([str(CTL), "--socket", str(SOCK), "--timeout", "3", *args],
capture_output=True, text=True, timeout=20)
return r.returncode == 0
def bench_running():
"""Наша игра на c.sock: по командной строке (--control-socket) или по живому сокету."""
if pgrep(str(SOCK.relative_to(ROOT))):
return True
if SOCK.exists():
if ctl_ok("status"):
return True
SOCK.unlink() # мёртвый сокет от прошлого прогона
return False
def ctl(*args, timeout=30.0):
cmd = [str(CTL), "--socket", str(SOCK), "--timeout", f"{timeout:g}", *map(str, args)]
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 60)
except subprocess.TimeoutExpired as e:
raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: таймаут") from e
if r.returncode != 0:
raise BenchError(f"horizon-ctl {' '.join(map(str, args))}: "
f"{(r.stdout or r.stderr).strip()[:300]}")
return r.stdout.strip()
def game_env(extra, timing):
env = dict(os.environ)
env.update({
"HORIZON_HOME": str(HOME),
"HORIZON_NO_AUDIO": "1",
"HORIZON_LAUNCHER_NO_SELF_UPDATE": "1",
})
if timing:
env["HORIZON_SHADER_PACK_TIMING"] = "1"
env.update(extra)
return env
def prepare_home():
if HOME.exists():
shutil.rmtree(HOME)
(HOME / "saves").mkdir(parents=True)
for world in sorted({s.world for s in SHOTS}):
src = SAVES / world
if not src.is_dir():
die(f"нет мира {src}")
shutil.copytree(src, HOME / "saves" / world,
ignore=shutil.ignore_patterns("session.lock", "*.lock"))
def install_mod(env):
subprocess.run([sys.executable, str(ROOT / "tools" / "airdef.py"), "install"],
env={**env, "HORIZON_HOME": str(HOME)}, check=True, cwd=ROOT)
def launch(world, env, game, resources):
if SOCK.exists():
SOCK.unlink()
cmd = [str(CTL), "--socket", str(SOCK), "launch", "--world", world, "--offscreen", "--wait",
"--game", str(game), "--resources", str(resources)]
r = subprocess.run(cmd, capture_output=True, text=True, env=env, cwd=HOME, timeout=600)
if r.returncode != 0:
raise BenchError(f"launch {world}: {(r.stdout or r.stderr).strip()[:400]}")
ctl("command", "gamemode creative")
ctl("key", "t", "tap") # чат, как игрок: /airdef shader hd
ctl("type", "/airdef shader hd")
ctl("key", "return", "tap")
time.sleep(0.5)
ctl("key", "f1", "tap") # скрыть HUD (один раз на запуск)
def stop_game():
try:
ctl("quit", timeout=20)
except BenchError:
pass
for _ in range(100):
if not ctl_ok("status"):
break
time.sleep(0.2)
if SOCK.exists() and not ctl_ok("status"):
SOCK.unlink()
# --- кадры: PNG без Pillow и с ним -------------------------------------------------------------
def _paeth(a, b, c):
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
return a if pa <= pb and pa <= pc else (b if pb <= pc else c)
def read_png_rgb(path):
"""(w, h, bytes RGB). Только 8-бит RGB/RGBA без чередования (так пишет игра)."""
data = Path(path).read_bytes()
if data[:8] != b"\x89PNG\r\n\x1a\n":
raise BenchError(f"{path}: не PNG")
pos, idat, w = 8, [], 0
while pos < len(data):
(ln,) = struct.unpack(">I", data[pos:pos + 4])
kind = data[pos + 4:pos + 8]
body = data[pos + 8:pos + 8 + ln]
pos += 12 + ln
if kind == b"IHDR":
w, h, depth, ctype, _, _, interlace = struct.unpack(">IIBBBBB", body)
elif kind == b"IDAT":
idat.append(body)
elif kind == b"IEND":
break
if depth != 8 or interlace or ctype not in (2, 6):
raise BenchError(f"{path}: нужен 8-бит RGB/RGBA без чередования")
bpp = 3 if ctype == 2 else 4
stride = w * bpp
raw = zlib.decompress(b"".join(idat))
out = bytearray(w * h * 3)
prev = bytearray(stride)
p = 0
for y in range(h):
ft = raw[p]
line = raw[p + 1:p + 1 + stride]
p += 1 + stride
cur = bytearray(stride)
for i in range(stride):
x = line[i]
a = cur[i - bpp] if i >= bpp else 0
b = prev[i]
c = prev[i - bpp] if i >= bpp else 0
if ft == 1:
x += a
elif ft == 2:
x += b
elif ft == 3:
x += (a + b) >> 1
elif ft == 4:
x += _paeth(a, b, c)
cur[i] = x & 255
base = y * w * 3
if bpp == 3:
out[base:base + w * 3] = cur
else:
out[base:base + w * 3:3] = cur[0::4]
out[base + 1:base + w * 3:3] = cur[1::4]
out[base + 2:base + w * 3:3] = cur[2::4]
prev = cur
return w, h, bytes(out)
def write_png_rgb(path, w, h, rgb):
raw = b"".join(b"\x00" + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h))
def chunk(kind, body):
return struct.pack(">I", len(body)) + kind + body + struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF)
Path(path).write_bytes(b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 6))
+ chunk(b"IEND", b""))
try: # необязательно: быстрее декодирование и сравнение
from PIL import Image, ImageChops, ImageStat
except ImportError: # pragma: no cover
Image = None
def compare_frames(a, b, diff_out=None):
"""Средняя разница по каналам (0..255) и доля пикселей с разницей > DIFF_LEVEL, в %."""
if Image is not None:
A = Image.open(a).convert("RGB")
B = Image.open(b).convert("RGB")
if A.size != B.size:
raise BenchError(f"размеры кадров разные: {A.size} и {B.size}")
d = ImageChops.difference(A, B)
mean = sum(ImageStat.Stat(d).mean) / 3
r, g, bl = d.split()
mx = ImageChops.lighter(ImageChops.lighter(r, g), bl)
hist = mx.histogram()
total = A.size[0] * A.size[1]
pct = 100.0 * sum(hist[DIFF_LEVEL + 1:]) / total
if diff_out:
d.point(lambda v: min(255, v * DIFF_GAIN)).save(diff_out)
return {"mean": mean, "pct": pct, "size": list(A.size)}
wa, ha, pa = read_png_rgb(a)
wb, hb, pb = read_png_rgb(b)
if (wa, ha) != (wb, hb):
raise BenchError(f"размеры кадров разные: {wa}x{ha} и {wb}x{hb}")
total_abs = 0
over = 0
diff = bytearray(len(pa)) if diff_out else None
for i in range(0, len(pa), 3):
dr = abs(pa[i] - pb[i])
dg = abs(pa[i + 1] - pb[i + 1])
db = abs(pa[i + 2] - pb[i + 2])
total_abs += dr + dg + db
if max(dr, dg, db) > DIFF_LEVEL:
over += 1
if diff is not None:
diff[i] = min(255, dr * DIFF_GAIN)
diff[i + 1] = min(255, dg * DIFF_GAIN)
diff[i + 2] = min(255, db * DIFF_GAIN)
if diff_out:
write_png_rgb(diff_out, wa, ha, bytes(diff))
npx = wa * ha
return {"mean": total_abs / (3.0 * npx), "pct": 100.0 * over / npx, "size": [wa, ha]}
# --- тайминги -----------------------------------------------------------------------------------
def read_from(path, offset):
try:
with open(path, "rb") as f:
f.seek(offset)
return f.read().decode("utf-8", "replace")
except FileNotFoundError:
return ""
def parse_pass_reports(text):
reports = []
for line in text.splitlines():
m = TIMING_RE.search(line)
if not m:
continue
passes = {}
for part in m.group(1).split(", "):
name, _, ms = part.rpartition(" ")
try:
passes[name] = float(ms)
except ValueError:
pass
reports.append((passes, float(m.group(2))))
return reports
def average_passes(reports):
if not reports:
return {}, None
sums, counts = {}, {}
for passes, _ in reports:
for name, ms in passes.items():
sums[name] = sums.get(name, 0.0) + ms
counts[name] = counts.get(name, 0) + 1
avg = {n: sums[n] / counts[n] for n in sums}
total = sum(avg.values())
return avg, total
def bench_gpu_avg(reply):
try:
return float(json.loads(reply)["gpu"]["avg_ms"])
except (ValueError, KeyError, TypeError):
return None
# --- сцена ------------------------------------------------------------------------------------
def run_shot(shot, out, settle, bench_secs, timing):
x, y, z, yaw, pitch = shot.pose
ctl("command", f"time set {shot.time}")
ctl("command", "weather clear")
ctl("command", f"tp @s {x} {y} {z} {yaw} {pitch}")
ctl("wait-ready", 120, timeout=150)
time.sleep(settle)
first = out / f"{shot.name}.png"
ctl("screenshot", str(first.resolve()))
time.sleep(0.5)
second = out / f"{shot.name}_still2.png"
ctl("screenshot", str(second.resolve()))
result = {"frame": first.name, "still2": second.name, "gpu_avg_ms": None, "passes": {},
"pass_total_ms": None, "bench_json": None}
offset = LOG.stat().st_size if LOG.exists() else 0
reply = ctl("bench", bench_secs, timeout=bench_secs + 60)
(out / f"{shot.name}_bench.json").write_text(reply + "\n")
result["bench_json"] = f"{shot.name}_bench.json"
result["gpu_avg_ms"] = bench_gpu_avg(reply)
if timing:
time.sleep(1.0) # последний отчёт о проходах
reports = parse_pass_reports(read_from(LOG, offset))
result["passes"], result["pass_total_ms"] = average_passes(reports)
if not reports:
result["warning"] = "нет строк проходов в логе (мало таймед-кадров?)"
return result
def group_by_world(shots):
order = []
for s in shots:
if s.world not in order:
order.append(s.world)
return [(w, [s for s in shots if s.world == w]) for w in order]
def git_rev():
r = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--short", "HEAD"],
capture_output=True, text=True)
return r.stdout.strip() or "?"
def judge(entry, args):
reasons = []
if entry.get("error"):
reasons.append("ошибка: " + entry["error"])
gpu = entry.get("gpu_avg_ms")
if gpu is not None and gpu > args.budget_ms:
reasons.append(f"GPU {gpu:.2f} мс > бюджета {args.budget_ms:g}")
if gpu is None and not entry.get("error"):
reasons.append("нет GPU-таймингов (bench)")
fl = entry.get("flicker")
if fl and fl["pct"] > args.max_flicker:
reasons.append(f"мерцание {fl['pct']:.2f}% > {args.max_flicker:g}%")
base = entry.get("vs_baseline")
if base:
if base.get("error"):
reasons.append(base["error"])
else:
if base["mean"] > args.max_mean:
reasons.append(f"разница с базой {base['mean']:.2f} > {args.max_mean:g}")
if base["pct"] > args.max_pct:
reasons.append(f"пикселей >20/255 {base['pct']:.2f}% > {args.max_pct:g}%")
return reasons
def write_report(out, entries, args, env_extra, size, baseline):
lines = []
add = lines.append
add(f"# HD-полигон: {out.name}")
add("")
add(f"- коммит: `{git_rev()}`, дата: {datetime.datetime.now():%Y-%m-%d %H:%M}")
add(f"- разрешение кадра: {size[0]}x{size[1]}" if size else "- разрешение кадра: ?")
add(f"- окружение игры: " + (", ".join(f"`{k}={v}`" for k, v in sorted(env_extra.items())) or "по умолчанию"))
add(f"- бюджет GPU: {args.budget_ms:g} мс (bench {args.bench_secs:g} с), мерцание <= {args.max_flicker:g}%")
if baseline:
add(f"- сравнение с: `{baseline}` (средняя разница <= {args.max_mean:g}, пикселей >20/255 <= {args.max_pct:g}%)")
add("")
add("| сцена | мир | время | GPU avg, мс | бюджет | мерцание %>20 | к базе: mean | к базе: %>20 | итог |")
add("|---|---|---|---|---|---|---|---|---|")
for e in entries:
gpu = f"{e['gpu_avg_ms']:.2f}" if e.get("gpu_avg_ms") is not None else "-"
fl = f"{e['flicker']['pct']:.2f}" if e.get("flicker") else "-"
b = e.get("vs_baseline") or {}
bm = f"{b['mean']:.2f}" if "mean" in b else "-"
bp = f"{b['pct']:.2f}" if "pct" in b else "-"
status = "OK" if not e["fail"] else "FAIL: " + "; ".join(e["fail"])
budget = "да" if e.get("gpu_avg_ms") is not None and e["gpu_avg_ms"] <= args.budget_ms else ("нет" if e.get("gpu_avg_ms") is not None else "-")
add(f"| {e['name']} | {e['world']} | {e['time']} | {gpu} | {budget} | {fl} | {bm} | {bp} | {status} |")
passes = []
for e in entries:
for p in e.get("passes", {}):
if p not in passes:
passes.append(p)
if passes:
add("")
add("## Проходы GPU, среднее по отчётам лога, мс")
add("")
add("| проход | " + " | ".join(e["name"] for e in entries) + " |")
add("|---|" + "---|" * len(entries))
for p in passes:
add(f"| {p} | " + " | ".join(f"{e['passes'][p]:.3f}" if p in e.get("passes", {}) else "-" for e in entries) + " |")
totals = [f"{e['pass_total_ms']:.3f}" if e.get("pass_total_ms") is not None else "-" for e in entries]
add("| **итого** | " + " | ".join(totals) + " |")
add("")
add("## Кадры")
for e in entries:
add("")
add(f"### {e['name']}: {e['note']}")
add("")
add(f"![{e['name']}]({e['frame']})")
if e.get("diff_image"):
add("")
add(f"Разница с базой (x{DIFF_GAIN}): ![diff]({e['diff_image']})")
if e.get("warning"):
add("")
add(f"> {e['warning']}")
if e.get("error"):
add("")
add(f"> ошибка: {e['error']}")
(out / "report.md").write_text("\n".join(lines) + "\n")
def parse_args(argv):
p = argparse.ArgumentParser(description="Графический полигон HD: кадры, мерцание, GPU-бюджет.")
p.add_argument("--list", action="store_true", help="показать сцены и выйти")
p.add_argument("--only", action="append", default=[], metavar="СЦЕНА", help="только эти сцены (можно несколько раз)")
p.add_argument("--out", help="папка вывода (по умолчанию run-home-bench/out/<время>)")
p.add_argument("--baseline", help="папка прошлого прогона для сравнения кадров")
p.add_argument("--env", action="append", default=[], metavar="КЛЮЧ=ЗНАЧЕНИЕ", help="переменная для игры, например HORIZON_HD_TAA=0")
p.add_argument("--budget-ms", type=float, default=18.0, help="бюджет GPU на кадр, мс (по умолчанию 18)")
p.add_argument("--bench-secs", type=float, default=4.0, help="сколько секунд bench (по умолчанию 4)")
p.add_argument("--settle", type=float, default=4.0, help="пауза после телепорта, с (по умолчанию 4)")
p.add_argument("--max-mean", type=float, default=3.0, help="порог средней разницы с базой, 0..255")
p.add_argument("--max-pct", type=float, default=5.0, help="порог пикселей >20/255 с базой, %%")
p.add_argument("--max-flicker", type=float, default=5.0, help="порог мерцания (два кадра подряд), %%")
p.add_argument("--no-pass-timing", action="store_true", help="не включать HORIZON_SHADER_PACK_TIMING")
p.add_argument("--game", help="horizon-client (по умолчанию как в tools/airdef.py)")
p.add_argument("--resources", help="подготовленные ресурсы Minecraft 26.3")
return p.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
if args.list:
for s in SHOTS:
print(f"{s.name:22} {s.world:8} время {s.time:5} поза {s.pose} {s.note}")
return 0
shots = [s for s in SHOTS if not args.only or s.name in args.only]
unknown = set(args.only) - {s.name for s in SHOTS}
if unknown:
die(f"нет сцен: {', '.join(sorted(unknown))} (см. --list)")
env_extra = {}
for item in args.env:
if "=" not in item:
die(f"--env ждёт КЛЮЧ=ЗНАЧЕНИЕ, получено: {item}")
k, v = item.split("=", 1)
env_extra[k] = v
if bench_running():
die("уже запущена игра на run-home-bench/c.sock: один прогон за раз")
if pgrep(OTHER_SOCK):
if len(shots) > 1:
die(f"работает чужая игра на {OTHER_SOCK}: запусти одну сцену (--only) или дождись конца теста")
print(f"hd_bench: внимание: работает чужая игра на {OTHER_SOCK}, снимок будет под нагрузкой",
file=sys.stderr)
out = Path(args.out) if args.out else BENCH / "out" / datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
out = out.resolve()
out.mkdir(parents=True, exist_ok=True)
sys.path.insert(0, str(ROOT / "tools"))
import airdef # tools/airdef.py: ресурсы и путь к игре
game = Path(args.game) if args.game else airdef.built_game()
resources = Path(args.resources) if args.resources else airdef.resources()
timing = not args.no_pass_timing
env = game_env(env_extra, timing)
prepare_home()
install_mod(env)
entries = []
baseline = Path(args.baseline).resolve() if args.baseline else None
try:
for world, group in group_by_world(shots):
try:
launch(world, env, game, resources)
except BenchError as e:
for s in group:
entries.append({**asdict(s), "error": str(e), "fail": [f"ошибка: {e}"]})
stop_game()
continue
for s in group:
entry = {**asdict(s), "frame": f"{s.name}.png"}
try:
entry.update(run_shot(s, out, args.settle, args.bench_secs, timing))
fl = compare_frames(out / f"{s.name}.png", out / f"{s.name}_still2.png")
entry["flicker"] = fl
except BenchError as e:
entry["error"] = str(e)
if baseline and not entry.get("error"):
base_png = baseline / f"{s.name}.png"
if base_png.exists():
try:
diff_name = f"{s.name}_diff.png"
m = compare_frames(base_png, out / f"{s.name}.png", out / diff_name)
entry["vs_baseline"] = m
entry["diff_image"] = diff_name
except BenchError as e:
entry["vs_baseline"] = {"error": str(e)}
else:
entry["vs_baseline"] = {"error": f"в базе нет {base_png.name}"}
entry["fail"] = judge(entry, args)
entries.append(entry)
stop_game()
finally:
stop_game()
size = None
for e in entries:
if e.get("vs_baseline") and "size" in e["vs_baseline"]:
size = e["vs_baseline"]["size"]
break
if size is None and entries and entries[0].get("flicker"):
size = entries[0]["flicker"]["size"]
results = {"out": str(out), "env": env_extra, "budget_ms": args.budget_ms,
"baseline": str(baseline) if baseline else None, "shots": entries}
(out / "results.json").write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n")
write_report(out, entries, args, env_extra, size, str(baseline) if baseline else None)
failed = [e for e in entries if e["fail"]]
for e in entries:
print(f"{e['name']:22} GPU {e.get('gpu_avg_ms') if e.get('gpu_avg_ms') is None else round(e['gpu_avg_ms'], 2)} мс "
f"мерцание {e['flicker']['pct']:.2f}%" if e.get("flicker") else f"{e['name']:22} {e.get('error', '')}")
for r in e["fail"]:
print(f" FAIL: {r}")
print(f"отчёт: {out / 'report.md'}")
return 1 if failed else 0
if __name__ == "__main__":
try:
sys.exit(main())
except BenchError as e:
die(str(e), code=2)