11 Commits

Author SHA1 Message Date
server
1eda856283 issue-4: expose biolog submit network helper 2026-04-16 17:26:51 +02:00
server
8a09d2c76a Project UI anchors from interpolated render state 2026-04-16 10:56:45 +02:00
server
bfe52a81f9 Add high-FPS render pacing and telemetry 2026-04-16 10:37:08 +02:00
server
49e8eac809 Revert stream M2Pack archive support 2026-04-15 19:06:59 +02:00
server
0b852faf0e Support experimental stream M2Pack archives 2026-04-15 18:40:40 +02:00
server
b353339bd8 Add GM smoke compare workflow for pack profiling 2026-04-15 17:35:02 +02:00
server
db7ae1f841 Persist pack profile snapshots during timed captures 2026-04-15 17:06:11 +02:00
server
2d9beb4793 Add pack profile capture workflow 2026-04-15 16:39:16 +02:00
server
6ff59498d2 Add pack profile report parser 2026-04-15 16:34:26 +02:00
server
ba6af8115b Add pack runtime profiling hooks 2026-04-15 16:22:10 +02:00
server
ef7cdf2809 Reduce m2pack client hot-path overhead 2026-04-15 15:43:26 +02:00
36 changed files with 4284 additions and 256 deletions

View File

@@ -0,0 +1,307 @@
# Anti-Cheat Architecture 2026
This document proposes a practical anti-cheat design for the current Metin2
client and server stack.
Date of analysis: 2026-04-16
## Executive Summary
The current client contains only a weak integrity layer:
- `src/UserInterface/ProcessCRC.cpp` computes CRC values for the running binary.
- `src/UserInterface/PythonNetworkStream.cpp` calls `BuildProcessCRC()` when the
client enters select phase.
- `src/UserInterface/PythonNetworkStreamPhaseGame.cpp` injects two CRC bytes
into attack packets.
- `src/UserInterface/PythonNetworkStreamPhaseGame.cpp` contains a `CG::HACK`
reporting path.
- `src/UserInterface/ProcessScanner.cpp` exists, but it is not wired into the
live runtime.
- `src/UserInterface/PythonNetworkStreamPhaseGame.cpp::__SendCRCReportPacket()`
is currently a stub that returns `true`.
This is not a production-grade anti-cheat. It raises the bar slightly for basic
binary tampering, but it does not defend well against modern external tools,
automation, packet abuse, or VM-assisted cheats.
The recommended 2026 design is:
1. Make the server authoritative for critical combat and movement rules.
2. Add telemetry and delayed-ban workflows.
3. Add a commercial client anti-cheat as a hardening layer, not as the primary
trust model.
4. Treat botting and farming as a separate abuse problem with separate signals.
## Goals
- Stop speedhack, combohack, rangehack, teleport, packet spam, and most forms of
client-side state forgery.
- Raise the cost of memory editing, DLL injection, and automation.
- Reduce false positives by moving final judgment to server-side evidence.
- Keep the design compatible with a legacy custom client.
## Non-Goals
- Absolute cheat prevention.
- Fully trusting a third-party kernel driver to solve game logic abuse.
- Real-time automatic bans on a single weak signal.
## Current State
### What exists today
- Client self-CRC:
- `src/UserInterface/ProcessCRC.cpp`
- CRC pieces attached to outgoing attack packets:
- `src/UserInterface/PythonNetworkStreamPhaseGame.cpp`
- Hack message queue and packet transport:
- `src/UserInterface/PythonNetworkStreamPhaseGame.cpp`
### What is missing today
- No active process blacklist or scanner integration in the runtime path.
- No complete CRC reporting flow to the server.
- No server-authoritative verification model documented in the current client
source tree.
- No robust evidence pipeline for review, delayed bans, or behavioral scoring.
## Recommended Architecture
### Layer 1: Server-Authoritative Core
This is the most important layer. The server must become the source of truth for
combat and movement outcomes.
### Movement validation
The server should validate:
- maximum velocity by actor state
- acceleration spikes
- position delta against elapsed server time
- warp-only transitions
- path continuity through collision or map rules
- impossible Z transitions and fly transitions
Recommended outputs:
- hard reject for impossible moves
- suspicion score for repeated marginal violations
- session telemetry record
### Combat validation
The server should validate:
- attack rate by skill and weapon class
- combo timing windows
- skill cooldowns
- victim distance and angle
- hit count per server tick
- target existence and visibility rules
- animation-independent skill gate timing
This removes most of the value of classic combohack, attack speed manipulation,
and packet replay.
### State validation
The server should own:
- HP / SP mutation rules
- buff durations
- item use success
- teleport authorization
- quest-critical transitions
- loot generation and pickup eligibility
### Layer 2: Telemetry and Evidence
Do not ban on one event unless the event is impossible by design.
Collect per-session evidence for:
- movement violations
- attack interval violations
- repeated rejected packets
- target distance anomalies
- client build mismatch
- CRC mismatch
- anti-cheat vendor verdict
- bot-like repetition and route loops
Recommended workflow:
- score each event
- decay score over time
- trigger thresholds for:
- silent logging
- shadow restrictions
- temporary action blocks
- GM review
- delayed ban wave
Delayed bans are important because instant bans teach attackers which checks
worked.
### Layer 3: Client Integrity
Use client integrity only as a supporting layer.
Recommended client responsibilities:
- verify binary and pack integrity
- report build identifier and signed manifest identity
- detect common injection or patching signals
- report tampering and environment metadata
- protect transport secrets and runtime config
Suggested upgrades to the current client:
- replace the current partial CRC path with a real signed build identity
- sign client packs and executable manifests
- complete `__SendCRCReportPacket()` and send useful integrity evidence
- remove dead anti-cheat code or wire it fully
- add secure telemetry batching instead of ad hoc string-only hack messages
### Layer 4: Commercial Anti-Cheat
### Recommended vendor order for this project
1. `XIGNCODE3`
2. `BattlEye`
3. `Denuvo Anti-Cheat`
4. `Easy Anti-Cheat`
### Why this order
`XIGNCODE3` looks like the best functional fit for a legacy MMO client:
- public positioning is strongly focused on online PC games
- public feature set includes macro detection, resource protection, and real
time pattern updates
- market fit appears closer to older custom launchers and non-mainstream engine
stacks
`BattlEye` is a strong option if you want a more established premium PC
anti-cheat and can support tighter integration work.
`Denuvo Anti-Cheat` is technically strong, but it is likely the heaviest vendor
path in both integration and commercial terms.
`Easy Anti-Cheat` is attractive if budget is the main constraint, but it should
not change the core rule: the server must still be authoritative.
### Layer 5: Anti-Bot and Economy Abuse
Botting should be treated as a separate control plane.
Recommended controls:
- route repetition heuristics
- farming loop detection
- vendor / warehouse / trade anomaly scoring
- captcha or interaction challenge only after confidence threshold
- account graph analysis for mule and funnel behavior
- hardware and device clustering where legally appropriate
Do not rely on captcha alone. It is only a friction tool.
## Proposed Rollout
### Phase 1: Fix trust boundaries
- document which outcomes are currently trusted from the client
- move movement and attack legality fully server-side
- add structured telemetry records
Deliverable:
- server can reject impossible movement and impossible attack cadence without any
client anti-cheat dependency
### Phase 2: Replace weak integrity
- complete binary and pack integrity reporting
- version all reports by client build
- bind reports to account, session, and channel
Deliverable:
- reliable client integrity evidence reaches the server
### Phase 3: Vendor pilot
- integrate one commercial anti-cheat in observe mode first
- compare vendor verdicts with server suspicion score
- review false positives before enforcement
Deliverable:
- enforcement policy based on combined evidence
### Phase 4: Ban and response pipeline
- implement delayed ban waves
- implement silent risk tiers
- implement GM review tooling
Deliverable:
- repeatable response process instead of ad hoc action
## Concrete Changes For This Codebase
Client-side work:
- `src/UserInterface/ProcessCRC.cpp`
- replace simple CRC flow with signed manifest and richer integrity report
- `src/UserInterface/PythonNetworkStream.cpp`
- send integrity bootstrap at phase transition
- `src/UserInterface/PythonNetworkStreamPhaseGame.cpp`
- implement real CRC and integrity packet submission
- replace free-form hack strings with typed evidence events
- `src/UserInterface/ProcessScanner.cpp`
- either remove dead code or reintroduce it as a fully designed subsystem
Server-side work:
- movement validator
- combat cadence validator
- anomaly scoring service
- evidence storage
- review and ban tooling
## Design Rules
- Never trust timing sent by the client for legality.
- Never trust client-side cooldown completion.
- Never trust client position as final truth for hit validation.
- Never ban permanently on a single client-only signal.
- Never ship anti-cheat changes without telemetry first.
## Success Metrics
- drop in successful speedhack and combohack reports
- drop in impossible movement accepted by the server
- reduced farm bot session length
- reduced economy inflation from automated abuse
- acceptable false positive rate during observe mode
## Recommended Next Step
Implement Layer 1 before vendor integration.
If the server still accepts impossible combat or movement, a commercial
anti-cheat only increases attacker cost. It does not fix the trust model.
## External References
- Unity cheat prevention guidance
- Epic Easy Anti-Cheat public developer material
- BattlEye public developer material
- Wellbia XIGNCODE3 public product material
- Denuvo Anti-Cheat public product material
- Metin2Dev discussions around server-side validation, anti-bot workflows, and
weak community anti-cheat releases

View File

@@ -0,0 +1,342 @@
# High-FPS Client Plan
This document describes how to move the current client from a hard 60 FPS model
to a safe high-FPS model without breaking gameplay timing.
Date of analysis: 2026-04-16
## Executive Summary
The current client is not limited to 60 FPS by a single config value. It is
limited by architecture.
Current hard constraints in the source:
- `src/UserInterface/PythonApplication.cpp`
- constructor switches `CTimer` into custom time mode
- main loop advances by a fixed `16/17 ms`
- main loop sleeps until the next tick
- `src/EterBase/Timer.cpp`
- custom mode returns `16 + (m_index & 1)` milliseconds per frame
- `src/GameLib/GameType.cpp`
- `g_fGameFPS = 60.0f`
- `src/GameLib/ActorInstanceMotion.cpp`
- motion frame math depends on `g_fGameFPS`
- `src/EterLib/GrpDevice.cpp`
- presentation interval is also involved, especially in fullscreen
Because of this, a simple FPS unlock would likely produce one or more of these
problems:
- broken combo timing
- incorrect animation frame stepping
- combat desync with the server
- accelerated or jittery local effects
- unstable camera or UI timing
The correct design is:
1. Keep simulation on a fixed tick.
2. Decouple rendering from simulation.
3. Add interpolation for render state.
4. Expose render cap and VSync as separate options.
## Current Timing Model
### Main loop
In `src/UserInterface/PythonApplication.cpp`:
- `CTimer::Instance().UseCustomTime()` is enabled in the constructor.
- `rkTimer.Advance()` advances simulated time.
- `GetElapsedMilliecond()` returns a fixed `16/17 ms`.
- `s_uiNextFrameTime += uiFrameTime` schedules the next frame.
- `Sleep(rest)` enforces the cap.
This means the current process loop is effectively built around a fixed 60 Hz
clock.
### Gameplay coupling
`src/GameLib/GameType.cpp` defines:
```cpp
extern float g_fGameFPS = 60.0f;
```
This value is used by motion and attack frame math in:
- `src/GameLib/ActorInstanceMotion.cpp`
- `src/GameLib/RaceMotionData.cpp`
The result is that update timing and motion timing are coupled to 60 FPS.
## Design Target
Target architecture:
- simulation update: fixed 60 Hz
- render: uncapped or capped to monitor / user value
- interpolation: enabled between fixed simulation states
- VSync: optional
- gameplay legality: unchanged from the server point of view
This lets the client render at:
- 120 FPS
- 144 FPS
- 165 FPS
- 240 FPS
- uncapped
while still keeping game logic deterministic.
## Recommended Implementation
### Step 1: Replace the custom fixed timer for frame scheduling
Do not delete all timer code immediately. First isolate responsibilities.
Split timing into:
- simulation accumulator time
- render frame delta
- presentation pacing
Recommended source touchpoints:
- `src/EterBase/Timer.cpp`
- `src/EterBase/Timer.h`
- `src/UserInterface/PythonApplication.cpp`
Preferred clock source:
- `QueryPerformanceCounter` / `QueryPerformanceFrequency`
- or a StepTimer-style wrapper with high-resolution real time
### Step 2: Convert the main loop to fixed update + variable render
Current model:
- one process pass
- one fixed pseudo-frame
- optional sleep
Target model:
```text
read real delta
accumulate delta
while accumulator >= fixedTick:
run simulation update
accumulator -= fixedTick
alpha = accumulator / fixedTick
render(alpha)
present
optional sleep for render cap
```
Recommended fixed tick:
- `16.666666 ms`
Recommended initial render caps:
- 60
- 120
- 144
- 165
- 240
- unlimited
### Step 3: Keep gameplay update on fixed 60 Hz
Do not increase gameplay tick in the first rollout.
Keep these systems on the fixed update path:
- network processing that depends on gameplay state
- actor motion update
- attack state transitions
- player movement simulation
- effect simulation when tied to gameplay
- camera state that depends on actor state
This reduces risk dramatically.
### Step 4: Render from interpolated state
The renderer must not directly depend on only the last fixed update result if
you want smooth motion at high refresh rates.
Add previous and current renderable state for:
- actor transform
- mount transform
- camera transform
- optionally important effect anchors
At render time:
- interpolate position
- slerp or interpolate rotation where needed
- use `alpha` from the simulation accumulator
Without this step, high-FPS output will still look like 60 FPS motion with extra
duplicate frames.
### Step 5: Separate render cap from VSync
The current D3D present settings are not enough on their own.
Recommended behavior:
- windowed:
- allow `immediate` plus optional software frame cap
- fullscreen:
- keep VSync configurable
- do not rely on fullscreen `PresentationInterval` as the only limiter
Source touchpoint:
- `src/EterLib/GrpDevice.cpp`
### Step 6: Make `SetFPS()` real or remove it
`app.SetFPS()` exists today but only stores `m_iFPS`. It does not drive the main
loop.
Source touchpoints:
- `src/UserInterface/PythonApplication.cpp`
- `src/UserInterface/PythonApplication.h`
- `src/UserInterface/PythonApplicationModule.cpp`
Required action:
- wire `m_iFPS` into the render pacing code
- or rename the API to reflect the real behavior
### Step 7: Audit all 60 FPS assumptions
Search and review every system that assumes 60 FPS timing.
Known touchpoints:
- `src/GameLib/GameType.cpp`
- `src/GameLib/ActorInstanceMotion.cpp`
- `src/GameLib/RaceMotionData.cpp`
- `src/AudioLib/MaSoundInstance.h`
- `src/AudioLib/SoundEngine.cpp`
- `src/AudioLib/Type.cpp`
Expected rule:
- gameplay timing stays fixed at 60 Hz for rollout 1
- render-only timing becomes variable
- audio smoothing constants may need review if they are implicitly tied to frame
rate instead of elapsed time
## Rollout Plan
### Phase 1: Mechanical decoupling
- introduce high-resolution real timer
- add accumulator-based fixed update loop
- keep simulation at 60 Hz
- render once per outer loop
Deliverable:
- no visible gameplay behavior change at 60 Hz cap
### Phase 2: Interpolation
- store previous and current render states
- render with interpolation alpha
- validate player, NPC, mount, and camera smoothness
Deliverable:
- motion looks smoother above 60 Hz
### Phase 3: Render settings
- implement `render_fps_limit`
- implement `vsync` toggle
- expose settings to Python and config
Deliverable:
- user-selectable 120 / 144 / 165 / 240 / unlimited
### Phase 4: Validation
Test matrix:
- 60 Hz monitor, VSync on and off
- 144 Hz monitor, cap 60 / 144 / unlimited
- minimized and alt-tab paths
- crowded city combat
- boss fight
- mounted combat
- loading transitions
- packet-heavy scenes
Metrics:
- stable attack cadence
- no combo timing regression
- no movement speed regression
- no animation stalls
- no camera jitter
- no CPU runaway when capped
## Risks
### High risk
- attack and combo logic tied to frame count
- animation transitions tied to frame count
- server-visible timing drift
### Medium risk
- effect systems that assume one render per update
- audio systems with frame-based smoothing
- UI code that assumes stable fixed frame cadence
### Lower risk
- D3D present configuration
- config plumbing for user settings
## Minimal Safe First Patch
If the goal is the fastest safe path, the first implementation should do only
this:
1. keep gameplay at fixed 60 Hz
2. render more often
3. interpolate actor and camera transforms
4. expose render cap option
Do not attempt in the first patch:
- 120 Hz gameplay simulation
- rewriting all motion logic to time-based math
- changing server combat timing
## Success Criteria
- client can render above 60 FPS on high refresh displays
- gameplay remains server-compatible
- no measurable change in combat legality
- 60 FPS mode still behaves like the current client
## Recommended Next Step
Implement Phase 1 and Phase 2 together in a branch.
That is the smallest meaningful change set that can produce visibly smoother
output without committing to a full time-based gameplay rewrite.

View File

@@ -0,0 +1,147 @@
# Pack Profile Analysis
The client can now emit a runtime pack profiler report into:
```text
log/pack_profile.txt
```
Enable it with either:
```bash
M2PACK_PROFILE=1 ./scripts/run-wine-headless.sh ./build-mingw64-lld/bin
```
or:
```bash
./scripts/run-wine-headless.sh ./build-mingw64-lld/bin -- --m2pack-profile
```
## Typical workflow
Collect two runs with the same scenario:
1. legacy `.pck` runtime
2. `m2p` runtime
After each run, copy or rename the profiler output so it is not overwritten:
```bash
cp build-mingw64-lld/bin/log/pack_profile.txt logs/pack_profile.pck.txt
cp build-mingw64-lld/bin/log/pack_profile.txt logs/pack_profile.m2p.txt
```
Then compare both runs:
```bash
python3 scripts/pack-profile-report.py \
pck=logs/pack_profile.pck.txt \
m2p=logs/pack_profile.m2p.txt
```
For repeated testing, use the wrapper scripts:
```bash
./scripts/capture-pack-profile.sh \
--runtime-root ../m2dev-client \
--label pck
```
This stages the runtime into `build-mingw64-lld/bin`, runs the client with
`M2PACK_PROFILE=1`, then archives:
- raw report: `build-mingw64-lld/bin/log/pack-profile-runs/<label>.pack_profile.txt`
- parsed summary: `build-mingw64-lld/bin/log/pack-profile-runs/<label>.summary.txt`
To run a full `pck` vs `m2p` comparison in one go:
```bash
./scripts/compare-pack-profile-runs.sh \
--left-label pck \
--left-runtime-root /path/to/runtime-pck \
--right-label m2p \
--right-runtime-root /path/to/runtime-m2p
```
The script captures both runs back-to-back and writes a combined compare report
into the same output directory.
## Real game-flow smoke compare
Startup-only runs are useful for bootstrap regressions, but they do not show the
real hot path once the client reaches `login`, `loading`, and `game`.
For that case, use the CH99 GM smoke compare wrapper:
```bash
python3 scripts/compare-pack-profile-gm-smoke.py \
--left-label pck-only \
--left-runtime-root /path/to/runtime-pck \
--right-label secure-mixed \
--right-runtime-root /path/to/runtime-m2p \
--master-key /path/to/master.key \
--sign-pubkey /path/to/signing.pub \
--account-login admin
```
What it does:
- copies the built client into a temporary workspace outside the repository
- stages each runtime into that workspace
- temporarily updates the selected GM account password and map position
- auto-logins through the special GM smoke channel (`11991` by default)
- enters game, performs one deterministic GM warp, archives `pack_profile.txt`
- restores the account password and the character map/position afterward
- deletes the temporary workspace unless `--keep-workspaces` is used
Archived outputs per run:
- raw report: `<out-dir>/<label>.pack_profile.txt`
- parsed summary: `<out-dir>/<label>.summary.txt`
- headless trace: `<out-dir>/<label>.headless_gm_teleport_trace.txt`
- startup trace when present: `<out-dir>/<label>.startup_trace.txt`
This flow is the current best approximation of a real client loading path on the
Linux-hosted Wine setup because it records phase markers beyond pure startup.
You can also summarize a single run:
```bash
python3 scripts/pack-profile-report.py logs/pack_profile.m2p.txt
```
## What to read first
`Packed Load Totals`
- Best top-level comparison for pack I/O cost in the measured run.
- Focus on `delta_ms` and `delta_pct`.
`Phase Markers`
- Shows where startup time actually moved.
- Useful for deciding whether the gain happened before login, during loading, or mostly in game.
`Load Time By Phase`
- Confirms which phase is paying for asset work.
- Usually the most important line is `loading`.
`Loader Stages`
- Shows whether the cost is mostly in decrypt or zstd.
- For `m2p`, expect small manifest overhead and the main costs in `aead_decrypt` and `zstd_decompress`.
- For legacy `.pck`, expect `xchacha20_decrypt` and `zstd_decompress`.
`Top Phase Pack Loads`
- Useful when the total difference is small, but one or two packs dominate the budget.
## Decision hints
If `Packed Load Totals` improve, but `Phase Markers` do not, the bottleneck is probably outside pack loading.
If `zstd_decompress` dominates both formats, the next lever is compression strategy and pack layout.
If decrypt dominates, the next lever is reducing decrypt work on the hot path or changing how often the same data is touched.

193
scripts/capture-pack-profile.sh Executable file
View File

@@ -0,0 +1,193 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
capture-pack-profile.sh [options] [build-bin-dir] [-- <client args...>]
Examples:
capture-pack-profile.sh --label pck
capture-pack-profile.sh --runtime-root ../m2dev-client --label baseline
capture-pack-profile.sh ./build-mingw64-lld/bin -- --m2pack-strict-hash 1
Options:
--runtime-root DIR Stage runtime content from DIR before launching
--label NAME Output label for the captured report
--out-dir DIR Directory for archived raw + summary reports
--timeout SEC Overrides M2_TIMEOUT for the headless run
--copy-runtime Materialize runtime instead of symlinking it
-h, --help Show this help
Behavior:
- Enables M2PACK_PROFILE=1 for the client run
- Archives build-bin-dir/log/pack_profile.txt under --out-dir
- Writes a parsed summary next to the raw report
- Treats timeout exit codes (124/137) as acceptable if the report exists
Environment overrides:
M2_STAGE_RUNTIME_SCRIPT path to stage-linux-runtime.sh
M2_HEADLESS_RUN_SCRIPT path to run-wine-headless.sh
M2_PACK_PROFILE_PARSER path to pack-profile-report.py
EOF
}
sanitize_label() {
printf '%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g'
}
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(realpath "$script_dir/..")"
default_build_bin_dir="$repo_root/build-mingw64-lld/bin"
default_runtime_root="$repo_root/../m2dev-client"
stage_script="${M2_STAGE_RUNTIME_SCRIPT:-$script_dir/stage-linux-runtime.sh}"
run_script="${M2_HEADLESS_RUN_SCRIPT:-$script_dir/run-wine-headless.sh}"
parser_script="${M2_PACK_PROFILE_PARSER:-$script_dir/pack-profile-report.py}"
runtime_root=""
label=""
out_dir=""
timeout_seconds="${M2_TIMEOUT:-20}"
copy_runtime=0
while [[ $# -gt 0 ]]; do
case "$1" in
--runtime-root)
runtime_root="$2"
shift 2
;;
--label)
label="$2"
shift 2
;;
--out-dir)
out_dir="$2"
shift 2
;;
--timeout)
timeout_seconds="$2"
shift 2
;;
--copy-runtime)
copy_runtime=1
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
echo "unknown option: $1" >&2
usage >&2
exit 1
;;
*)
break
;;
esac
done
build_bin_dir="${1:-$default_build_bin_dir}"
if [[ $# -gt 0 ]]; then
shift
fi
build_bin_dir="$(realpath -m "$build_bin_dir")"
if [[ -z "$label" ]]; then
label="$(date +%Y%m%d-%H%M%S)"
fi
safe_label="$(sanitize_label "$label")"
if [[ -z "$safe_label" ]]; then
safe_label="capture"
fi
if [[ -z "$out_dir" ]]; then
out_dir="$build_bin_dir/log/pack-profile-runs"
fi
out_dir="$(realpath -m "$out_dir")"
if [[ -z "$runtime_root" ]]; then
if [[ ! -e "$build_bin_dir/config/locale.cfg" || ! -e "$build_bin_dir/pack" ]]; then
if [[ -d "$default_runtime_root" ]]; then
runtime_root="$default_runtime_root"
fi
fi
fi
if [[ -n "$runtime_root" ]]; then
runtime_root="$(realpath "$runtime_root")"
fi
if [[ ! -x "$run_script" ]]; then
echo "headless runner not executable: $run_script" >&2
exit 1
fi
if [[ ! -f "$parser_script" ]]; then
echo "pack profile parser not found: $parser_script" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 not found in PATH" >&2
exit 1
fi
mkdir -p "$build_bin_dir/log" "$out_dir"
if [[ -n "$runtime_root" ]]; then
if [[ ! -x "$stage_script" ]]; then
echo "runtime staging script not executable: $stage_script" >&2
exit 1
fi
stage_args=()
if [[ "$copy_runtime" -eq 1 ]]; then
stage_args+=(--copy)
fi
stage_args+=("$runtime_root" "$build_bin_dir")
"$stage_script" "${stage_args[@]}"
fi
report_path="$build_bin_dir/log/pack_profile.txt"
raw_out="$out_dir/$safe_label.pack_profile.txt"
summary_out="$out_dir/$safe_label.summary.txt"
rm -f "$report_path"
set +e
M2PACK_PROFILE=1 M2_TIMEOUT="$timeout_seconds" "$run_script" "$build_bin_dir" -- "$@"
run_exit=$?
set -e
if [[ ! -f "$report_path" ]]; then
echo "pack profile report was not generated: $report_path" >&2
exit "$run_exit"
fi
cp "$report_path" "$raw_out"
python3 "$parser_script" "$raw_out" > "$summary_out"
case "$run_exit" in
0|124|137)
;;
*)
echo "client run exited with $run_exit; archived report anyway:" >&2
echo " raw: $raw_out" >&2
echo " summary: $summary_out" >&2
exit "$run_exit"
;;
esac
echo "captured pack profile:"
echo " raw: $raw_out"
echo " summary: $summary_out"

View File

@@ -0,0 +1,728 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import secrets
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
DEFAULT_SERVER_HOST = "173.249.9.66"
DEFAULT_AUTH_PORT = 11000
DEFAULT_CHANNEL_PORT = 11991
DEFAULT_MAP_NAME = "gm_guild_build"
DEFAULT_MAP_INDEX = 200
DEFAULT_GLOBAL_X = 96000
DEFAULT_GLOBAL_Y = 12800
ACCEPTABLE_RUN_CODES = {0, 124, 137}
@dataclass
class PlayerState:
player_id: int
name: str
map_index: int
x: int
y: int
@dataclass
class AccountState:
account_id: int
password_hash: str
player: PlayerState
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Run a real login -> game -> GM warp pack profile comparison under "
"Wine using two runtime roots."
),
)
parser.add_argument(
"--left-runtime-root",
type=Path,
required=True,
help="Runtime root for the first capture.",
)
parser.add_argument(
"--right-runtime-root",
type=Path,
required=True,
help="Runtime root for the second capture.",
)
parser.add_argument(
"--left-label",
default="left",
help="Label for the first capture.",
)
parser.add_argument(
"--right-label",
default="right",
help="Label for the second capture.",
)
parser.add_argument(
"--master-key",
type=Path,
required=True,
help="Path to the runtime master key hex file.",
)
parser.add_argument(
"--sign-pubkey",
type=Path,
required=True,
help="Path to the signing public key hex file.",
)
parser.add_argument(
"--key-id",
default="1",
help="Runtime key_id for the secure pack loader.",
)
parser.add_argument(
"--account-login",
default="admin",
help="GM account used for the smoke flow.",
)
parser.add_argument(
"--slot",
type=int,
default=0,
help="Character slot used for auto-select.",
)
parser.add_argument(
"--timeout",
type=int,
default=90,
help="Headless client timeout in seconds per capture.",
)
parser.add_argument(
"--server-host",
default=DEFAULT_SERVER_HOST,
help="Server host used in loginInfo.py.",
)
parser.add_argument(
"--auth-port",
type=int,
default=DEFAULT_AUTH_PORT,
help="Auth port used in loginInfo.py.",
)
parser.add_argument(
"--channel-port",
type=int,
default=DEFAULT_CHANNEL_PORT,
help="Channel port used in loginInfo.py.",
)
parser.add_argument(
"--map-name",
default=DEFAULT_MAP_NAME,
help="Headless GM target map name.",
)
parser.add_argument(
"--map-index",
type=int,
default=DEFAULT_MAP_INDEX,
help="Server-side map index used to place the GM character before login.",
)
parser.add_argument(
"--global-x",
type=int,
default=DEFAULT_GLOBAL_X,
help="Global X coordinate used for the initial load and GM warp target.",
)
parser.add_argument(
"--global-y",
type=int,
default=DEFAULT_GLOBAL_Y,
help="Global Y coordinate used for the initial load and GM warp target.",
)
parser.add_argument(
"--command-delay",
type=float,
default=3.0,
help="Delay before the headless GM sends the first /warp command.",
)
parser.add_argument(
"--settle-delay",
type=float,
default=1.0,
help="Delay between warp arrival and the next GM command.",
)
parser.add_argument(
"--warp-timeout",
type=float,
default=15.0,
help="Timeout per GM warp step in seconds.",
)
parser.add_argument(
"--out-dir",
type=Path,
default=None,
help="Directory for archived reports and compare output.",
)
parser.add_argument(
"--work-root",
type=Path,
default=None,
help="Directory where temporary workspaces are created.",
)
parser.add_argument(
"--keep-workspaces",
action="store_true",
help="Keep temporary workspaces for debugging instead of deleting them.",
)
parser.add_argument(
"source_build_bin_dir",
nargs="?",
default=None,
help="Source build/bin directory to copy out of the repository.",
)
return parser.parse_args()
def sanitize_label(value: str) -> str:
safe = []
prev_dash = False
for ch in value.lower():
keep = ch.isalnum() or ch in "._-"
if keep:
if ch == "-" and prev_dash:
continue
safe.append(ch)
prev_dash = ch == "-"
else:
if not prev_dash:
safe.append("-")
prev_dash = True
result = "".join(safe).strip("-")
return result or "capture"
def require_tool(name: str) -> None:
if shutil.which(name) is None:
raise SystemExit(f"{name} not found in PATH")
def run_checked(args: list[str], *, cwd: Path | None = None, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
completed = subprocess.run(
args,
cwd=str(cwd) if cwd else None,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise RuntimeError(f"command failed ({completed.returncode}): {' '.join(args)}\n{detail}")
return completed
def run_mysql(database: str, sql: str) -> str:
completed = subprocess.run(
["mysql", "-B", "-N", database, "-e", sql],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise RuntimeError(f"mysql failed: {detail}")
return completed.stdout.strip()
def sql_quote(value: str) -> str:
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
def load_account_state(account_login: str, slot: int) -> AccountState:
if slot < 0 or slot > 3:
raise SystemExit(f"unsupported slot index: {slot}")
account_id_text = run_mysql(
"account",
f"SELECT id FROM account WHERE login={sql_quote(account_login)} LIMIT 1;",
)
if not account_id_text:
raise SystemExit(f"account not found: {account_login}")
account_id = int(account_id_text)
password_hash = run_mysql(
"account",
f"SELECT password FROM account WHERE login={sql_quote(account_login)} LIMIT 1;",
)
if not password_hash:
raise SystemExit(f"account password hash not found: {account_login}")
pid_text = run_mysql(
"player",
f"SELECT pid{slot + 1} FROM player_index WHERE id={account_id} LIMIT 1;",
)
if not pid_text or pid_text == "0":
raise SystemExit(f"slot {slot} has no character for account: {account_login}")
player_id = int(pid_text)
player_row = run_mysql(
"player",
f"SELECT id, name, map_index, x, y FROM player WHERE id={player_id} LIMIT 1;",
)
if not player_row:
raise SystemExit(f"player row not found for id={player_id}")
parts = player_row.split("\t")
if len(parts) != 5:
raise SystemExit(f"unexpected player row format for id={player_id}: {player_row}")
return AccountState(
account_id=account_id,
password_hash=password_hash,
player=PlayerState(
player_id=int(parts[0]),
name=parts[1],
map_index=int(parts[2]),
x=int(parts[3]),
y=int(parts[4]),
),
)
def restore_account_state(account_login: str, state: AccountState) -> None:
run_mysql(
"account",
(
"UPDATE account SET password=%s WHERE login=%s;"
% (sql_quote(state.password_hash), sql_quote(account_login))
),
)
run_mysql(
"player",
(
"UPDATE player SET map_index=%d, x=%d, y=%d WHERE id=%d;"
% (
state.player.map_index,
state.player.x,
state.player.y,
state.player.player_id,
)
),
)
def verify_account_state(account_login: str, slot: int, expected: AccountState) -> None:
current = load_account_state(account_login, slot)
if current.password_hash != expected.password_hash:
raise RuntimeError(
f"account password hash did not restore for {account_login}"
)
if (
current.player.map_index != expected.player.map_index
or current.player.x != expected.player.x
or current.player.y != expected.player.y
or current.player.player_id != expected.player.player_id
):
raise RuntimeError(
"player state did not restore for "
f"{account_login}: expected map={expected.player.map_index} "
f"x={expected.player.x} y={expected.player.y}, got "
f"map={current.player.map_index} x={current.player.x} y={current.player.y}"
)
def set_temp_account_state(
account_login: str,
player_id: int,
*,
temp_password: str,
map_index: int,
global_x: int,
global_y: int,
) -> None:
run_mysql(
"account",
(
"UPDATE account SET password=PASSWORD(%s) WHERE login=%s;"
% (sql_quote(temp_password), sql_quote(account_login))
),
)
run_mysql(
"player",
(
"UPDATE player SET map_index=%d, x=%d, y=%d WHERE id=%d;"
% (map_index, global_x, global_y, player_id)
),
)
def remove_previous_logs(build_bin_dir: Path) -> None:
log_dir = build_bin_dir / "log"
log_dir.mkdir(parents=True, exist_ok=True)
for path in log_dir.glob("*.txt"):
path.unlink(missing_ok=True)
for path in [build_bin_dir / "syserr.txt", build_bin_dir / "ErrorLog.txt", build_bin_dir / "loginInfo.py"]:
path.unlink(missing_ok=True)
def write_login_info(
build_bin_dir: Path,
*,
account_login: str,
temp_password: str,
slot: int,
server_host: str,
auth_port: int,
channel_port: int,
) -> None:
login_info = "\n".join(
[
f"addr={server_host!r}",
f"port={channel_port}",
f"account_addr={server_host!r}",
f"account_port={auth_port}",
f"id={account_login!r}",
f"pwd={temp_password!r}",
f"slot={slot}",
"autoLogin=1",
"autoSelect=1",
"",
]
)
(build_bin_dir / "loginInfo.py").write_text(login_info, encoding="utf-8")
def copy_source_bin(source_build_bin_dir: Path, workspace_bin_dir: Path) -> None:
run_checked(
[
"rsync",
"-a",
"--delete",
"--exclude",
"config",
"--exclude",
"pack",
"--exclude",
"mark",
"--exclude",
"log",
"--exclude",
"BGM",
"--exclude",
"bgm",
"--exclude",
"locale",
"--exclude",
"sound",
f"{source_build_bin_dir}/",
str(workspace_bin_dir),
]
)
def copy_if_exists(src: Path, dst: Path) -> None:
if src.exists():
shutil.copy2(src, dst)
def prepare_runtime_wrapper(runtime_root: Path, wrapper_root: Path) -> None:
if not (runtime_root / "config").exists():
raise SystemExit(f"runtime config missing: {runtime_root / 'config'}")
if not (runtime_root / "pack").exists():
raise SystemExit(f"runtime pack missing: {runtime_root / 'pack'}")
wrapper_root.mkdir(parents=True, exist_ok=True)
def link_entry(name: str, target: Path) -> None:
link_path = wrapper_root / name
if link_path.exists() or link_path.is_symlink():
link_path.unlink()
os.symlink(target, link_path)
link_entry("config", runtime_root / "config")
link_entry("pack", runtime_root / "pack")
optional_entries = {
"mark": runtime_root / "mark",
"locale": runtime_root / "locale",
"sound": runtime_root / "sound",
}
bgm_target = runtime_root / "BGM"
if not bgm_target.exists():
bgm_target = runtime_root / "bgm"
if bgm_target.exists():
optional_entries["BGM"] = bgm_target
for name, target in optional_entries.items():
if target.exists():
link_entry(name, target)
def capture_run(
*,
label: str,
runtime_root: Path,
source_build_bin_dir: Path,
out_dir: Path,
work_root: Path | None,
keep_workspace: bool,
master_key_hex: str,
sign_pubkey_hex: str,
key_id: str,
account_login: str,
slot: int,
timeout_seconds: int,
server_host: str,
auth_port: int,
channel_port: int,
map_name: str,
map_index: int,
global_x: int,
global_y: int,
command_delay: float,
settle_delay: float,
warp_timeout: float,
stage_script: Path,
run_script: Path,
parser_script: Path,
) -> tuple[Path, Path | None]:
workspace_path = Path(
tempfile.mkdtemp(
prefix=f"pack-profile-gm-smoke-{sanitize_label(label)}.",
dir=str(work_root) if work_root else None,
)
)
workspace_bin_dir = workspace_path / "bin"
workspace_runtime_dir = workspace_path / "runtime"
workspace_bin_dir.mkdir(parents=True, exist_ok=True)
account_state = load_account_state(account_login, slot)
temp_password = ("Tmp" + secrets.token_hex(6))[:16]
safe_label = sanitize_label(label)
raw_out = out_dir / f"{safe_label}.pack_profile.txt"
summary_out = out_dir / f"{safe_label}.summary.txt"
trace_out = out_dir / f"{safe_label}.headless_gm_teleport_trace.txt"
startup_out = out_dir / f"{safe_label}.startup_trace.txt"
syserr_out = out_dir / f"{safe_label}.syserr.txt"
try:
copy_source_bin(source_build_bin_dir, workspace_bin_dir)
remove_previous_logs(workspace_bin_dir)
prepare_runtime_wrapper(runtime_root, workspace_runtime_dir)
run_checked(["bash", str(stage_script), str(workspace_runtime_dir), str(workspace_bin_dir)])
write_login_info(
workspace_bin_dir,
account_login=account_login,
temp_password=temp_password,
slot=slot,
server_host=server_host,
auth_port=auth_port,
channel_port=channel_port,
)
set_temp_account_state(
account_login,
account_state.player.player_id,
temp_password=temp_password,
map_index=map_index,
global_x=global_x,
global_y=global_y,
)
env = os.environ.copy()
env.update(
{
"M2PACK_PROFILE": "1",
"M2PACK_MASTER_KEY_HEX": master_key_hex,
"M2PACK_SIGN_PUBKEY_HEX": sign_pubkey_hex,
"M2PACK_KEY_ID": key_id,
"M2_TIMEOUT": str(timeout_seconds),
"M2_HEADLESS_SCENARIO": "gm_teleport",
"M2_HEADLESS_WARP_STEPS": f"{map_name},{global_x},{global_y}",
"M2_HEADLESS_COMMAND_DELAY": str(command_delay),
"M2_HEADLESS_SETTLE_DELAY": str(settle_delay),
"M2_HEADLESS_WARP_TIMEOUT": str(warp_timeout),
}
)
env.setdefault("WINEDEBUG", "-all")
completed = subprocess.run(
["bash", str(run_script), str(workspace_bin_dir)],
cwd=str(workspace_bin_dir),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
report_path = workspace_bin_dir / "log" / "pack_profile.txt"
if not report_path.is_file():
detail = completed.stderr.strip() or completed.stdout.strip()
raise RuntimeError(f"pack profile report not generated for {label}: {detail}")
trace_path = workspace_bin_dir / "log" / "headless_gm_teleport_trace.txt"
startup_path = workspace_bin_dir / "log" / "startup_trace.txt"
syserr_path = workspace_bin_dir / "syserr.txt"
shutil.copy2(report_path, raw_out)
copy_if_exists(trace_path, trace_out)
copy_if_exists(startup_path, startup_out)
copy_if_exists(syserr_path, syserr_out)
summary = run_checked([sys.executable, str(parser_script), str(raw_out)])
summary_out.write_text(summary.stdout, encoding="utf-8")
trace_text = trace_path.read_text(encoding="utf-8", errors="ignore") if trace_path.exists() else ""
scenario_ok = "Scenario success current_map=" in trace_text
if completed.returncode not in ACCEPTABLE_RUN_CODES:
detail = completed.stderr.strip() or completed.stdout.strip()
raise RuntimeError(
f"client run for {label} exited with {completed.returncode}: {detail}"
)
if not scenario_ok:
raise RuntimeError(
f"GM smoke scenario for {label} did not finish successfully; "
f"see {trace_out if trace_out.exists() else trace_path}"
)
return raw_out, (workspace_path if keep_workspace else None)
finally:
try:
restore_account_state(account_login, account_state)
verify_account_state(account_login, slot, account_state)
finally:
if not keep_workspace:
shutil.rmtree(workspace_path, ignore_errors=True)
def main() -> int:
args = parse_args()
require_tool("mysql")
require_tool("rsync")
require_tool("python3")
script_dir = Path(__file__).resolve().parent
repo_root = script_dir.parent
source_build_bin_dir = (
Path(args.source_build_bin_dir).resolve()
if args.source_build_bin_dir
else (repo_root / "build-mingw64-lld" / "bin").resolve()
)
if not (source_build_bin_dir / "Metin2_RelWithDebInfo.exe").is_file():
raise SystemExit(f"client exe not found: {source_build_bin_dir / 'Metin2_RelWithDebInfo.exe'}")
stage_script = script_dir / "stage-linux-runtime.sh"
run_script = script_dir / "run-wine-headless.sh"
parser_script = script_dir / "pack-profile-report.py"
for path, kind in [
(stage_script, "runtime stage script"),
(run_script, "headless run script"),
(parser_script, "pack profile parser"),
]:
if not path.exists():
raise SystemExit(f"{kind} not found: {path}")
out_dir = (
args.out_dir.resolve()
if args.out_dir
else (source_build_bin_dir / "log" / "pack-profile-gm-smoke" / next(tempfile._get_candidate_names())).resolve()
)
out_dir.mkdir(parents=True, exist_ok=True)
work_root = args.work_root.resolve() if args.work_root else None
if work_root:
work_root.mkdir(parents=True, exist_ok=True)
master_key_hex = args.master_key.read_text(encoding="utf-8").strip()
sign_pubkey_hex = args.sign_pubkey.read_text(encoding="utf-8").strip()
if not master_key_hex:
raise SystemExit(f"master key file is empty: {args.master_key}")
if not sign_pubkey_hex:
raise SystemExit(f"sign pubkey file is empty: {args.sign_pubkey}")
left_report, left_workspace = capture_run(
label=args.left_label,
runtime_root=args.left_runtime_root.resolve(),
source_build_bin_dir=source_build_bin_dir,
out_dir=out_dir,
work_root=work_root,
keep_workspace=args.keep_workspaces,
master_key_hex=master_key_hex,
sign_pubkey_hex=sign_pubkey_hex,
key_id=args.key_id,
account_login=args.account_login,
slot=args.slot,
timeout_seconds=args.timeout,
server_host=args.server_host,
auth_port=args.auth_port,
channel_port=args.channel_port,
map_name=args.map_name,
map_index=args.map_index,
global_x=args.global_x,
global_y=args.global_y,
command_delay=args.command_delay,
settle_delay=args.settle_delay,
warp_timeout=args.warp_timeout,
stage_script=stage_script,
run_script=run_script,
parser_script=parser_script,
)
right_report, right_workspace = capture_run(
label=args.right_label,
runtime_root=args.right_runtime_root.resolve(),
source_build_bin_dir=source_build_bin_dir,
out_dir=out_dir,
work_root=work_root,
keep_workspace=args.keep_workspaces,
master_key_hex=master_key_hex,
sign_pubkey_hex=sign_pubkey_hex,
key_id=args.key_id,
account_login=args.account_login,
slot=args.slot,
timeout_seconds=args.timeout,
server_host=args.server_host,
auth_port=args.auth_port,
channel_port=args.channel_port,
map_name=args.map_name,
map_index=args.map_index,
global_x=args.global_x,
global_y=args.global_y,
command_delay=args.command_delay,
settle_delay=args.settle_delay,
warp_timeout=args.warp_timeout,
stage_script=stage_script,
run_script=run_script,
parser_script=parser_script,
)
compare = run_checked(
[
sys.executable,
str(parser_script),
f"{args.left_label}={left_report}",
f"{args.right_label}={right_report}",
]
)
compare_path = out_dir / (
f"compare-{sanitize_label(args.left_label)}-vs-{sanitize_label(args.right_label)}.txt"
)
compare_path.write_text(compare.stdout, encoding="utf-8")
print(compare.stdout, end="")
print()
print(f"saved compare report: {compare_path}")
if left_workspace:
print(f"kept workspace: {left_workspace}")
if right_workspace:
print(f"kept workspace: {right_workspace}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,176 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
compare-pack-profile-runs.sh [options] [build-bin-dir] [-- <client args...>]
Examples:
compare-pack-profile-runs.sh \
--left-label pck \
--left-runtime-root /srv/client-runtime-pck \
--right-label m2p \
--right-runtime-root /srv/client-runtime-m2p
compare-pack-profile-runs.sh \
--left-label legacy \
--left-runtime-root ../runtime-pck \
--right-label secure \
--right-runtime-root ../runtime-m2p \
./build-mingw64-lld/bin -- --m2pack-strict-hash 0
Options:
--left-runtime-root DIR Runtime root for the first run
--right-runtime-root DIR Runtime root for the second run
--left-label NAME Label for the first run (default: left)
--right-label NAME Label for the second run (default: right)
--out-dir DIR Directory for archived reports and compare output
--timeout SEC Overrides M2_TIMEOUT for both runs
--copy-runtime Materialize runtime instead of symlinking it
-h, --help Show this help
Behavior:
- Runs two captures back-to-back into the same build output
- Archives both raw reports and summaries
- Writes a combined compare report and prints it to stdout
EOF
}
sanitize_label() {
printf '%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9._-]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g'
}
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(realpath "$script_dir/..")"
default_build_bin_dir="$repo_root/build-mingw64-lld/bin"
capture_script="${M2_CAPTURE_PACK_PROFILE_SCRIPT:-$script_dir/capture-pack-profile.sh}"
parser_script="${M2_PACK_PROFILE_PARSER:-$script_dir/pack-profile-report.py}"
left_runtime_root=""
right_runtime_root=""
left_label="left"
right_label="right"
out_dir=""
timeout_seconds="${M2_TIMEOUT:-20}"
copy_runtime=0
while [[ $# -gt 0 ]]; do
case "$1" in
--left-runtime-root)
left_runtime_root="$2"
shift 2
;;
--right-runtime-root)
right_runtime_root="$2"
shift 2
;;
--left-label)
left_label="$2"
shift 2
;;
--right-label)
right_label="$2"
shift 2
;;
--out-dir)
out_dir="$2"
shift 2
;;
--timeout)
timeout_seconds="$2"
shift 2
;;
--copy-runtime)
copy_runtime=1
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
echo "unknown option: $1" >&2
usage >&2
exit 1
;;
*)
break
;;
esac
done
if [[ -z "$left_runtime_root" || -z "$right_runtime_root" ]]; then
echo "both --left-runtime-root and --right-runtime-root are required" >&2
usage >&2
exit 1
fi
build_bin_dir="${1:-$default_build_bin_dir}"
if [[ $# -gt 0 ]]; then
shift
fi
build_bin_dir="$(realpath -m "$build_bin_dir")"
if [[ -z "$out_dir" ]]; then
out_dir="$build_bin_dir/log/pack-profile-runs/$(date +%Y%m%d-%H%M%S)"
fi
out_dir="$(realpath -m "$out_dir")"
mkdir -p "$out_dir"
if [[ ! -x "$capture_script" ]]; then
echo "capture script not executable: $capture_script" >&2
exit 1
fi
if [[ ! -f "$parser_script" ]]; then
echo "pack profile parser not found: $parser_script" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "python3 not found in PATH" >&2
exit 1
fi
capture_common_args=(
--out-dir "$out_dir"
--timeout "$timeout_seconds"
)
if [[ "$copy_runtime" -eq 1 ]]; then
capture_common_args+=(--copy-runtime)
fi
"$capture_script" \
"${capture_common_args[@]}" \
--runtime-root "$left_runtime_root" \
--label "$left_label" \
"$build_bin_dir" \
-- "$@"
"$capture_script" \
"${capture_common_args[@]}" \
--runtime-root "$right_runtime_root" \
--label "$right_label" \
"$build_bin_dir" \
-- "$@"
left_safe_label="$(sanitize_label "$left_label")"
right_safe_label="$(sanitize_label "$right_label")"
left_report="$out_dir/$left_safe_label.pack_profile.txt"
right_report="$out_dir/$right_safe_label.pack_profile.txt"
compare_path="$out_dir/compare-$left_safe_label-vs-$right_safe_label.txt"
python3 "$parser_script" \
"$left_label=$left_report" \
"$right_label=$right_report" | tee "$compare_path"
echo
echo "saved compare report: $compare_path"

755
scripts/pack-profile-report.py Executable file
View File

@@ -0,0 +1,755 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import math
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable
TABLE_SECTIONS = {
"mounts_by_format",
"mounts_by_pack",
"loads_by_format",
"loads_by_phase_format",
"top_phase_pack_loads",
"top_extension_loads",
"loader_stages",
}
@dataclass
class Report:
label: str
path: Path
metadata: dict[str, str] = field(default_factory=dict)
phase_markers: list[tuple[str, float]] = field(default_factory=list)
tables: dict[str, dict[str, dict[str, float]]] = field(default_factory=dict)
def uptime_ms(self) -> float:
return parse_float(self.metadata.get("uptime_ms", "0"))
def current_phase(self) -> str:
return self.metadata.get("current_phase", "-")
def reason(self) -> str:
return self.metadata.get("reason", "-")
def section(self, name: str) -> dict[str, dict[str, float]]:
return self.tables.get(name, {})
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Summarize or compare pack_profile.txt reports generated by the "
"client-side M2 pack profiler."
),
)
parser.add_argument(
"reports",
nargs="+",
metavar="REPORT",
help=(
"Either /path/to/pack_profile.txt or label=/path/to/pack_profile.txt. "
"Pass one report for a summary, or two or more reports for a comparison."
),
)
parser.add_argument(
"--top",
type=int,
default=6,
help="How many hotspot rows to show in top-pack, top-extension and stage sections.",
)
return parser.parse_args()
def parse_float(value: str) -> float:
try:
return float(value.strip())
except ValueError:
return 0.0
def parse_metric_value(value: str) -> float:
value = value.strip()
if value.endswith(" ms"):
value = value[:-3]
return parse_float(value)
def parse_report_arg(value: str) -> tuple[str, Path]:
if "=" in value:
label, path_value = value.split("=", 1)
if label and path_value:
return label, Path(path_value)
path = Path(value)
return path.stem, path
def load_report(arg_value: str) -> Report:
label, path = parse_report_arg(arg_value)
if not path.is_file():
raise FileNotFoundError(f"report not found: {path}")
report = Report(label=label, path=path)
current_section: str | None = None
for raw_line in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw_line.strip()
if not line or line == "PACK PROFILE REPORT":
continue
if line.startswith("[") and line.endswith("]"):
current_section = line[1:-1]
if current_section in TABLE_SECTIONS:
report.tables.setdefault(current_section, {})
continue
if current_section is None:
if "=" in line:
key, value = line.split("=", 1)
report.metadata[key.strip()] = value.strip()
continue
if current_section == "phase_markers":
if "\t" not in line:
continue
phase, value = line.split("\t", 1)
report.phase_markers.append((phase.strip(), parse_metric_value(value)))
continue
if current_section not in TABLE_SECTIONS:
continue
fields = [field.strip() for field in line.split("\t") if field.strip()]
if not fields:
continue
key = fields[0]
metrics: dict[str, float] = {}
for field in fields[1:]:
if "=" not in field:
continue
metric_key, metric_value = field.split("=", 1)
metrics[metric_key.strip()] = parse_metric_value(metric_value)
report.tables[current_section][key] = metrics
return report
def aggregate_phase_loads(report: Report) -> dict[str, dict[str, float]]:
aggregated: dict[str, dict[str, float]] = {}
for key, metrics in report.section("loads_by_phase_format").items():
phase, _, _format = key.partition("|")
phase_key = phase.strip()
bucket = aggregated.setdefault(
phase_key,
{"count": 0.0, "fail": 0.0, "bytes": 0.0, "time_ms": 0.0},
)
for metric_name in ("count", "fail", "bytes", "time_ms"):
bucket[metric_name] += metrics.get(metric_name, 0.0)
return aggregated
def sum_section_metrics(section: dict[str, dict[str, float]]) -> dict[str, float]:
totals: dict[str, float] = {}
for metrics in section.values():
for key, value in metrics.items():
totals[key] = totals.get(key, 0.0) + value
return totals
def sum_selected_section_metrics(
section: dict[str, dict[str, float]],
include_keys: Iterable[str] | None = None,
exclude_keys: Iterable[str] | None = None,
) -> dict[str, float]:
include_set = set(include_keys or [])
exclude_set = set(exclude_keys or [])
filtered: dict[str, dict[str, float]] = {}
for key, metrics in section.items():
if include_set and key not in include_set:
continue
if key in exclude_set:
continue
filtered[key] = metrics
return sum_section_metrics(filtered)
def sort_rows_by_metric(
section: dict[str, dict[str, float]],
metric: str,
limit: int,
) -> list[tuple[str, dict[str, float]]]:
rows = list(section.items())
rows.sort(key=lambda item: (-item[1].get(metric, 0.0), item[0]))
return rows[:limit]
def format_ms(value: float) -> str:
return f"{value:.3f}"
def format_delta(value: float) -> str:
return f"{value:+.3f}"
def format_percent(value: float | None) -> str:
if value is None or math.isinf(value) or math.isnan(value):
return "-"
return f"{value:+.1f}%"
def format_bytes(value: float) -> str:
units = ("B", "KiB", "MiB", "GiB")
size = float(value)
unit_index = 0
while abs(size) >= 1024.0 and unit_index < len(units) - 1:
size /= 1024.0
unit_index += 1
if unit_index == 0:
return f"{int(round(size))} {units[unit_index]}"
return f"{size:.2f} {units[unit_index]}"
def format_count(value: float) -> str:
return str(int(round(value)))
def format_metric(metric: str, value: float) -> str:
if metric in {"bytes", "in", "out"}:
return format_bytes(value)
if metric in {"time_ms"}:
return format_ms(value)
if metric in {"count", "fail", "entries"}:
return format_count(value)
if metric.endswith("_ms"):
return format_ms(value)
if metric.endswith("_us"):
return f"{value:.1f}"
if abs(value - round(value)) < 0.000001:
return format_count(value)
return f"{value:.3f}"
def render_table(headers: list[str], rows: list[list[str]], numeric_columns: set[int] | None = None) -> str:
numeric_columns = numeric_columns or set()
widths = [len(header) for header in headers]
for row in rows:
for index, cell in enumerate(row):
widths[index] = max(widths[index], len(cell))
lines = []
header_cells = []
for index, header in enumerate(headers):
align = str.rjust if index in numeric_columns else str.ljust
header_cells.append(align(header, widths[index]))
lines.append(" ".join(header_cells))
divider = []
for index, width in enumerate(widths):
divider.append(("-" * width))
lines.append(" ".join(divider))
for row in rows:
cells = []
for index, cell in enumerate(row):
align = str.rjust if index in numeric_columns else str.ljust
cells.append(align(cell, widths[index]))
lines.append(" ".join(cells))
return "\n".join(lines)
def relative_delta_percent(base: float, candidate: float) -> float | None:
if abs(base) < 0.000001:
return None
return ((candidate - base) / base) * 100.0
def summarize_report(report: Report, top: int) -> str:
lines = [
f"== {report.label} ==",
f"path: {report.path}",
(
f"reason={report.reason()} phase={report.current_phase()} "
f"uptime_ms={format_ms(report.uptime_ms())}"
),
"",
]
load_totals = sum_section_metrics(report.section("loads_by_format"))
mount_totals = sum_section_metrics(report.section("mounts_by_format"))
overview_rows = [[
format_count(load_totals.get("count", 0.0)),
format_ms(load_totals.get("time_ms", 0.0)),
format_bytes(load_totals.get("bytes", 0.0)),
format_count(load_totals.get("fail", 0.0)),
format_count(mount_totals.get("count", 0.0)),
format_ms(mount_totals.get("time_ms", 0.0)),
]]
lines.extend([
"Overview",
render_table(
["loads", "load_ms", "load_bytes", "load_fail", "mounts", "mount_ms"],
overview_rows,
numeric_columns={0, 1, 2, 3, 4, 5},
),
"",
])
if report.phase_markers:
phase_rows = [[phase, format_ms(value)] for phase, value in report.phase_markers]
lines.extend([
"Phase Markers",
render_table(["phase", "ms"], phase_rows, numeric_columns={1}),
"",
])
loads_by_format_rows = []
for key, metrics in sort_rows_by_metric(report.section("loads_by_format"), "time_ms", top):
loads_by_format_rows.append([
key,
format_count(metrics.get("count", 0.0)),
format_ms(metrics.get("time_ms", 0.0)),
format_bytes(metrics.get("bytes", 0.0)),
format_count(metrics.get("fail", 0.0)),
])
if loads_by_format_rows:
lines.extend([
"Loads By Format",
render_table(
["format", "count", "time_ms", "bytes", "fail"],
loads_by_format_rows,
numeric_columns={1, 2, 3, 4},
),
"",
])
mounts_by_format_rows = []
for key, metrics in sort_rows_by_metric(report.section("mounts_by_format"), "time_ms", top):
mounts_by_format_rows.append([
key,
format_count(metrics.get("count", 0.0)),
format_ms(metrics.get("time_ms", 0.0)),
format_count(metrics.get("entries", 0.0)),
format_count(metrics.get("fail", 0.0)),
])
if mounts_by_format_rows:
lines.extend([
"Mounts By Format",
render_table(
["format", "count", "time_ms", "entries", "fail"],
mounts_by_format_rows,
numeric_columns={1, 2, 3, 4},
),
"",
])
phase_load_rows = []
for key, metrics in sort_rows_by_metric(aggregate_phase_loads(report), "time_ms", top):
phase_load_rows.append([
key,
format_count(metrics.get("count", 0.0)),
format_ms(metrics.get("time_ms", 0.0)),
format_bytes(metrics.get("bytes", 0.0)),
format_count(metrics.get("fail", 0.0)),
])
if phase_load_rows:
lines.extend([
"Load Time By Phase",
render_table(
["phase", "count", "time_ms", "bytes", "fail"],
phase_load_rows,
numeric_columns={1, 2, 3, 4},
),
"",
])
stage_rows = []
for key, metrics in sort_rows_by_metric(report.section("loader_stages"), "time_ms", top):
count = metrics.get("count", 0.0)
avg_us = (metrics.get("time_ms", 0.0) * 1000.0 / count) if count else 0.0
stage_rows.append([
key,
format_count(count),
format_ms(metrics.get("time_ms", 0.0)),
f"{avg_us:.1f}",
format_bytes(metrics.get("in", 0.0)),
format_bytes(metrics.get("out", 0.0)),
])
if stage_rows:
lines.extend([
"Top Loader Stages",
render_table(
["stage", "count", "time_ms", "avg_us", "in", "out"],
stage_rows,
numeric_columns={1, 2, 3, 4, 5},
),
"",
])
hot_pack_rows = []
for key, metrics in sort_rows_by_metric(report.section("top_phase_pack_loads"), "time_ms", top):
hot_pack_rows.append([
key,
format_count(metrics.get("count", 0.0)),
format_ms(metrics.get("time_ms", 0.0)),
format_bytes(metrics.get("bytes", 0.0)),
])
if hot_pack_rows:
lines.extend([
"Top Phase Pack Loads",
render_table(
["phase | pack", "count", "time_ms", "bytes"],
hot_pack_rows,
numeric_columns={1, 2, 3},
),
"",
])
extension_rows = []
for key, metrics in sort_rows_by_metric(report.section("top_extension_loads"), "time_ms", top):
extension_rows.append([
key,
format_count(metrics.get("count", 0.0)),
format_ms(metrics.get("time_ms", 0.0)),
format_bytes(metrics.get("bytes", 0.0)),
])
if extension_rows:
lines.extend([
"Top Extensions",
render_table(
["extension", "count", "time_ms", "bytes"],
extension_rows,
numeric_columns={1, 2, 3},
),
"",
])
return "\n".join(lines).rstrip()
def compare_two_reports(left: Report, right: Report, top: int) -> str:
lines = [f"== {left.label} vs {right.label} ==", ""]
overview_rows = []
for report in (left, right):
load_totals = sum_section_metrics(report.section("loads_by_format"))
mount_totals = sum_section_metrics(report.section("mounts_by_format"))
overview_rows.append([
report.label,
format_ms(report.uptime_ms()),
format_count(load_totals.get("count", 0.0)),
format_ms(load_totals.get("time_ms", 0.0)),
format_bytes(load_totals.get("bytes", 0.0)),
format_count(mount_totals.get("count", 0.0)),
format_ms(mount_totals.get("time_ms", 0.0)),
])
lines.extend([
"Overview",
render_table(
["report", "uptime_ms", "loads", "load_ms", "load_bytes", "mounts", "mount_ms"],
overview_rows,
numeric_columns={1, 2, 3, 4, 5, 6},
),
"",
])
left_packed_loads = sum_selected_section_metrics(left.section("loads_by_format"), exclude_keys={"disk"})
right_packed_loads = sum_selected_section_metrics(right.section("loads_by_format"), exclude_keys={"disk"})
packed_load_rows = [[
format_count(left_packed_loads.get("count", 0.0)),
format_ms(left_packed_loads.get("time_ms", 0.0)),
format_bytes(left_packed_loads.get("bytes", 0.0)),
format_count(right_packed_loads.get("count", 0.0)),
format_ms(right_packed_loads.get("time_ms", 0.0)),
format_bytes(right_packed_loads.get("bytes", 0.0)),
format_delta(right_packed_loads.get("time_ms", 0.0) - left_packed_loads.get("time_ms", 0.0)),
format_percent(relative_delta_percent(left_packed_loads.get("time_ms", 0.0), right_packed_loads.get("time_ms", 0.0))),
]]
lines.extend([
"Packed Load Totals",
render_table(
[
f"{left.label}_count",
f"{left.label}_ms",
f"{left.label}_bytes",
f"{right.label}_count",
f"{right.label}_ms",
f"{right.label}_bytes",
"delta_ms",
"delta_pct",
],
packed_load_rows,
numeric_columns={0, 1, 2, 3, 4, 5, 6, 7},
),
"",
])
left_packed_mounts = sum_selected_section_metrics(left.section("mounts_by_format"))
right_packed_mounts = sum_selected_section_metrics(right.section("mounts_by_format"))
packed_mount_rows = [[
format_count(left_packed_mounts.get("count", 0.0)),
format_ms(left_packed_mounts.get("time_ms", 0.0)),
format_count(left_packed_mounts.get("entries", 0.0)),
format_count(right_packed_mounts.get("count", 0.0)),
format_ms(right_packed_mounts.get("time_ms", 0.0)),
format_count(right_packed_mounts.get("entries", 0.0)),
format_delta(right_packed_mounts.get("time_ms", 0.0) - left_packed_mounts.get("time_ms", 0.0)),
format_percent(relative_delta_percent(left_packed_mounts.get("time_ms", 0.0), right_packed_mounts.get("time_ms", 0.0))),
]]
if packed_mount_rows:
lines.extend([
"Packed Mount Totals",
render_table(
[
f"{left.label}_count",
f"{left.label}_ms",
f"{left.label}_entries",
f"{right.label}_count",
f"{right.label}_ms",
f"{right.label}_entries",
"delta_ms",
"delta_pct",
],
packed_mount_rows,
numeric_columns={0, 1, 2, 3, 4, 5, 6, 7},
),
"",
])
left_phases = dict(left.phase_markers)
right_phases = dict(right.phase_markers)
ordered_phases = [phase for phase, _ in left.phase_markers]
ordered_phases.extend(phase for phase, _ in right.phase_markers if phase not in left_phases)
phase_rows = []
for phase in ordered_phases:
left_value = left_phases.get(phase)
right_value = right_phases.get(phase)
if left_value is None and right_value is None:
continue
delta = (right_value or 0.0) - (left_value or 0.0)
delta_pct = relative_delta_percent(left_value or 0.0, right_value or 0.0)
phase_rows.append([
phase,
"-" if left_value is None else format_ms(left_value),
"-" if right_value is None else format_ms(right_value),
format_delta(delta),
format_percent(delta_pct),
])
if phase_rows:
lines.extend([
"Phase Markers",
render_table(
["phase", left.label, right.label, "delta_ms", "delta_pct"],
phase_rows,
numeric_columns={1, 2, 3, 4},
),
"",
])
left_phase_loads = aggregate_phase_loads(left)
right_phase_loads = aggregate_phase_loads(right)
phase_names = sorted(set(left_phase_loads) | set(right_phase_loads))
phase_load_rows = []
for phase in phase_names:
left_metrics = left_phase_loads.get(phase, {})
right_metrics = right_phase_loads.get(phase, {})
left_time = left_metrics.get("time_ms", 0.0)
right_time = right_metrics.get("time_ms", 0.0)
phase_load_rows.append([
phase,
format_ms(left_time),
format_ms(right_time),
format_delta(right_time - left_time),
format_percent(relative_delta_percent(left_time, right_time)),
format_count(left_metrics.get("count", 0.0)),
format_count(right_metrics.get("count", 0.0)),
])
if phase_load_rows:
phase_load_rows.sort(key=lambda row: (-max(parse_float(row[1]), parse_float(row[2])), row[0]))
lines.extend([
"Load Time By Phase",
render_table(
["phase", f"{left.label}_ms", f"{right.label}_ms", "delta_ms", "delta_pct", f"{left.label}_count", f"{right.label}_count"],
phase_load_rows,
numeric_columns={1, 2, 3, 4, 5, 6},
),
"",
])
format_names = sorted(set(left.section("loads_by_format")) | set(right.section("loads_by_format")))
format_rows = []
for format_name in format_names:
left_metrics = left.section("loads_by_format").get(format_name, {})
right_metrics = right.section("loads_by_format").get(format_name, {})
left_time = left_metrics.get("time_ms", 0.0)
right_time = right_metrics.get("time_ms", 0.0)
format_rows.append([
format_name,
format_count(left_metrics.get("count", 0.0)),
format_ms(left_time),
format_bytes(left_metrics.get("bytes", 0.0)),
format_count(right_metrics.get("count", 0.0)),
format_ms(right_time),
format_bytes(right_metrics.get("bytes", 0.0)),
format_delta(right_time - left_time),
format_percent(relative_delta_percent(left_time, right_time)),
])
if format_rows:
format_rows.sort(key=lambda row: (-max(parse_float(row[2]), parse_float(row[5])), row[0]))
lines.extend([
"Loads By Format",
render_table(
[
"format",
f"{left.label}_count",
f"{left.label}_ms",
f"{left.label}_bytes",
f"{right.label}_count",
f"{right.label}_ms",
f"{right.label}_bytes",
"delta_ms",
"delta_pct",
],
format_rows,
numeric_columns={1, 2, 3, 4, 5, 6, 7, 8},
),
"",
])
mount_names = sorted(set(left.section("mounts_by_format")) | set(right.section("mounts_by_format")))
mount_rows = []
for format_name in mount_names:
left_metrics = left.section("mounts_by_format").get(format_name, {})
right_metrics = right.section("mounts_by_format").get(format_name, {})
left_time = left_metrics.get("time_ms", 0.0)
right_time = right_metrics.get("time_ms", 0.0)
mount_rows.append([
format_name,
format_count(left_metrics.get("count", 0.0)),
format_ms(left_time),
format_count(left_metrics.get("entries", 0.0)),
format_count(right_metrics.get("count", 0.0)),
format_ms(right_time),
format_count(right_metrics.get("entries", 0.0)),
format_delta(right_time - left_time),
format_percent(relative_delta_percent(left_time, right_time)),
])
if mount_rows:
mount_rows.sort(key=lambda row: (-max(parse_float(row[2]), parse_float(row[5])), row[0]))
lines.extend([
"Mounts By Format",
render_table(
[
"format",
f"{left.label}_count",
f"{left.label}_ms",
f"{left.label}_entries",
f"{right.label}_count",
f"{right.label}_ms",
f"{right.label}_entries",
"delta_ms",
"delta_pct",
],
mount_rows,
numeric_columns={1, 2, 3, 4, 5, 6, 7, 8},
),
"",
])
stage_names = sorted(set(left.section("loader_stages")) | set(right.section("loader_stages")))
stage_rows = []
for stage_name in stage_names:
left_metrics = left.section("loader_stages").get(stage_name, {})
right_metrics = right.section("loader_stages").get(stage_name, {})
left_time = left_metrics.get("time_ms", 0.0)
right_time = right_metrics.get("time_ms", 0.0)
left_count = left_metrics.get("count", 0.0)
right_count = right_metrics.get("count", 0.0)
left_avg_us = (left_time * 1000.0 / left_count) if left_count else 0.0
right_avg_us = (right_time * 1000.0 / right_count) if right_count else 0.0
stage_rows.append([
stage_name,
format_ms(left_time),
f"{left_avg_us:.1f}",
format_ms(right_time),
f"{right_avg_us:.1f}",
format_delta(right_time - left_time),
format_percent(relative_delta_percent(left_time, right_time)),
])
if stage_rows:
stage_rows.sort(key=lambda row: (-max(parse_float(row[1]), parse_float(row[3])), row[0]))
lines.extend([
"Loader Stages",
render_table(
[
"stage",
f"{left.label}_ms",
f"{left.label}_avg_us",
f"{right.label}_ms",
f"{right.label}_avg_us",
"delta_ms",
"delta_pct",
],
stage_rows[:top],
numeric_columns={1, 2, 3, 4, 5, 6},
),
"",
])
for report in (left, right):
hot_rows = []
for key, metrics in sort_rows_by_metric(report.section("top_phase_pack_loads"), "time_ms", top):
hot_rows.append([
key,
format_count(metrics.get("count", 0.0)),
format_ms(metrics.get("time_ms", 0.0)),
format_bytes(metrics.get("bytes", 0.0)),
])
if hot_rows:
lines.extend([
f"Top Phase Pack Loads: {report.label}",
render_table(
["phase | pack", "count", "time_ms", "bytes"],
hot_rows,
numeric_columns={1, 2, 3},
),
"",
])
return "\n".join(lines).rstrip()
def summarize_many_reports(reports: list[Report], top: int) -> str:
if len(reports) == 2:
return compare_two_reports(reports[0], reports[1], top)
parts = []
for report in reports:
parts.append(summarize_report(report, top))
return "\n\n".join(parts)
def main() -> int:
args = parse_args()
try:
reports = [load_report(value) for value in args.reports]
except FileNotFoundError as exc:
print(str(exc), file=sys.stderr)
return 1
print(summarize_many_reports(reports, max(args.top, 1)))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -65,6 +65,11 @@ VOID ELTimer_SetFrameMSec()
gs_dwFrameTime = ELTimer_GetMSec();
}
VOID ELTimer_SetFrameMSecValue(DWORD dwFrameTime)
{
gs_dwFrameTime = dwFrameTime;
}
CTimer::CTimer()
{
ELTimer_Init();

View File

@@ -38,4 +38,5 @@ VOID ELTimer_SetServerMSec(DWORD dwServerTime);
DWORD ELTimer_GetServerMSec();
VOID ELTimer_SetFrameMSec();
DWORD ELTimer_GetFrameMSec();
VOID ELTimer_SetFrameMSecValue(DWORD dwFrameTime);
DWORD ELTimer_GetFrameMSec();

View File

@@ -18,6 +18,9 @@ void CActorInstance::INSTANCEBASE_Deform()
void CActorInstance::INSTANCEBASE_Transform()
{
m_v3InterpolationStartPosition = D3DXVECTOR3(m_x, m_y, m_z);
m_fInterpolationStartRotation = m_fcurRotation;
if (m_pkHorse)
{
m_pkHorse->INSTANCEBASE_Transform();
@@ -44,6 +47,30 @@ void CActorInstance::INSTANCEBASE_Transform()
UpdateAttribute();
}
void CActorInstance::ApplyRenderInterpolation(float fInterpolation)
{
const float fAlpha = std::max(0.0f, std::min(1.0f, fInterpolation));
D3DXVECTOR3 v3InterpolatedPosition;
v3InterpolatedPosition.x = m_v3InterpolationStartPosition.x + (m_x - m_v3InterpolationStartPosition.x) * fAlpha;
v3InterpolatedPosition.y = m_v3InterpolationStartPosition.y + (m_y - m_v3InterpolationStartPosition.y) * fAlpha;
v3InterpolatedPosition.z = m_v3InterpolationStartPosition.z + (m_z - m_v3InterpolationStartPosition.z) * fAlpha;
const float fInterpolatedRotation = GetInterpolatedRotation(m_fInterpolationStartRotation, m_fcurRotation, fAlpha);
SetPosition(v3InterpolatedPosition);
if (0.0f != m_rotX || 0.0f != m_rotY)
{
CGraphicObjectInstance::SetRotation(m_rotX, m_rotY, fInterpolatedRotation);
}
else
{
CGraphicObjectInstance::SetRotation(fInterpolatedRotation);
}
Transform();
}
void CActorInstance::OnUpdate()
{
@@ -875,6 +902,7 @@ void CActorInstance::__InitializeRotationData()
{
m_fAtkDirRot = 0.0f;
m_fcurRotation = 0.0f;
m_fInterpolationStartRotation = 0.0f;
m_rotBegin = 0.0f;
m_rotEnd = 0.0f;
m_rotEndTime = 0.0f;

View File

@@ -425,6 +425,7 @@ class CActorInstance : public IActorInstance, public IFlyTargetableObject
void LookAt(CActorInstance * pInstance);
void LookWith(CActorInstance * pInstance);
void LookAtFromXY(float x, float y, CActorInstance * pDestInstance);
void ApplyRenderInterpolation(float fInterpolation);
void SetReachScale(float fScale);
@@ -767,6 +768,7 @@ class CActorInstance : public IActorInstance, public IFlyTargetableObject
float m_x;
float m_y;
float m_z;
D3DXVECTOR3 m_v3InterpolationStartPosition;
D3DXVECTOR3 m_v3Pos;
D3DXVECTOR3 m_v3Movement;
BOOL m_bNeedUpdateCollision;
@@ -779,6 +781,7 @@ class CActorInstance : public IActorInstance, public IFlyTargetableObject
// Rotation
float m_fcurRotation;
float m_fInterpolationStartRotation;
float m_rotBegin;
float m_rotEnd;
float m_rotEndTime;

View File

@@ -1,4 +1,4 @@
file(GLOB_RECURSE FILE_SOURCES "*.h" "*.c" "*.cpp")
file(GLOB_RECURSE FILE_SOURCES CONFIGURE_DEPENDS "*.h" "*.c" "*.cpp")
add_library(PackLib STATIC ${FILE_SOURCES})

View File

@@ -1,6 +1,8 @@
#include "M2Pack.h"
#include "PackProfile.h"
#include "M2PackRuntimeKeyProvider.h"
#include <chrono>
#include <cstring>
#include <zstd.h>
@@ -40,6 +42,7 @@ bool ReadPod(const uint8_t* bytes, std::size_t size, std::size_t& offset, T& out
bool CM2Pack::Load(const std::string& path)
{
m_source_path = path;
std::error_code ec;
m_file.map(path, ec);
@@ -100,6 +103,7 @@ bool CM2Pack::Load(const std::string& path)
bool CM2Pack::ValidateManifest()
{
std::array<uint8_t, M2PACK_HASH_SIZE> manifest_hash {};
const auto hashStart = std::chrono::steady_clock::now();
crypto_generichash(
manifest_hash.data(),
manifest_hash.size(),
@@ -107,6 +111,13 @@ bool CM2Pack::ValidateManifest()
m_manifest_bytes.size(),
nullptr,
0);
RecordPackProfileStage(
"m2p",
"manifest_hash",
m_manifest_bytes.size(),
manifest_hash.size(),
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - hashStart).count()));
if (memcmp(manifest_hash.data(), m_header.manifest_hash, manifest_hash.size()) != 0)
{
@@ -121,17 +132,33 @@ bool CM2Pack::ValidateManifest()
return false;
}
const auto verifyStart = std::chrono::steady_clock::now();
if (crypto_sign_verify_detached(
m_header.manifest_signature,
m_manifest_bytes.data(),
m_manifest_bytes.size(),
publicKey->data()) != 0)
{
RecordPackProfileStage(
"m2p",
"manifest_signature",
m_manifest_bytes.size(),
M2PACK_SIGNATURE_SIZE,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - verifyStart).count()));
TraceError("CM2Pack::ValidateManifest: manifest signature mismatch");
return false;
}
RecordPackProfileStage(
"m2p",
"manifest_signature",
m_manifest_bytes.size(),
M2PACK_SIGNATURE_SIZE,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - verifyStart).count()));
std::size_t offset = 0;
const auto parseStart = std::chrono::steady_clock::now();
TM2PackManifestHeader manifest_header {};
if (!ReadPod(m_manifest_bytes.data(), m_manifest_bytes.size(), offset, manifest_header))
{
@@ -184,30 +211,33 @@ bool CM2Pack::ValidateManifest()
m_index.push_back(std::move(entry));
}
RecordPackProfileStage(
"m2p",
"manifest_parse",
m_manifest_bytes.size(),
m_index.size(),
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - parseStart).count()));
return true;
}
bool CM2Pack::DecryptEntryPayload(const TM2PackEntry& entry, std::vector<uint8_t>& decrypted, CBufferPool* pPool)
{
const uint64_t begin = sizeof(TM2PackHeader) + entry.data_offset;
const auto* ciphertext = reinterpret_cast<const uint8_t*>(m_file.data() + begin);
std::vector<uint8_t> ciphertext_copy;
const auto* ciphertext = reinterpret_cast<const unsigned char*>(m_file.data() + begin);
if (pPool)
{
ciphertext_copy = pPool->Acquire(entry.stored_size);
decrypted = pPool->Acquire(entry.stored_size);
}
ciphertext_copy.resize(entry.stored_size);
memcpy(ciphertext_copy.data(), ciphertext, entry.stored_size);
decrypted.resize(entry.stored_size);
const auto decryptStart = std::chrono::steady_clock::now();
unsigned long long written = 0;
if (crypto_aead_xchacha20poly1305_ietf_decrypt(
decrypted.data(),
&written,
nullptr,
ciphertext_copy.data(),
ciphertext_copy.size(),
ciphertext,
entry.stored_size,
reinterpret_cast<const unsigned char*>(entry.path.data()),
entry.path.size(),
entry.nonce.data(),
@@ -215,16 +245,19 @@ bool CM2Pack::DecryptEntryPayload(const TM2PackEntry& entry, std::vector<uint8_t
{
if (pPool)
{
pPool->Release(std::move(ciphertext_copy));
pPool->Release(std::move(decrypted));
}
return false;
}
RecordPackProfileStage(
"m2p",
"aead_decrypt",
entry.stored_size,
written,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - decryptStart).count()));
decrypted.resize(static_cast<std::size_t>(written));
if (pPool)
{
pPool->Release(std::move(ciphertext_copy));
}
return true;
}
@@ -252,7 +285,19 @@ bool CM2Pack::GetFileWithPool(const TM2PackEntry& entry, std::vector<uint8_t>& r
{
result.resize(entry.original_size);
ZSTD_DCtx* dctx = GetThreadLocalZstdContext();
const auto decompressStart = std::chrono::steady_clock::now();
size_t written = ZSTD_decompressDCtx(dctx, result.data(), result.size(), compressed.data(), compressed.size());
RecordPackProfileStage(
"m2p",
"zstd_decompress",
compressed.size(),
ZSTD_isError(written) ? 0 : written,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - decompressStart).count()));
if (pPool)
{
pPool->Release(std::move(compressed));
}
if (ZSTD_isError(written) || written != entry.original_size)
{
TraceError("CM2Pack::GetFileWithPool: zstd failed for '%s'", entry.path.c_str());
@@ -262,11 +307,21 @@ bool CM2Pack::GetFileWithPool(const TM2PackEntry& entry, std::vector<uint8_t>& r
}
default:
if (pPool)
{
pPool->Release(std::move(compressed));
}
TraceError("CM2Pack::GetFileWithPool: unsupported compression %u for '%s'", entry.compression, entry.path.c_str());
return false;
}
if (!ShouldVerifyM2PackPlaintextHash())
{
return true;
}
std::array<uint8_t, M2PACK_HASH_SIZE> plain_hash {};
const auto hashStart = std::chrono::steady_clock::now();
crypto_generichash(
plain_hash.data(),
plain_hash.size(),
@@ -274,6 +329,13 @@ bool CM2Pack::GetFileWithPool(const TM2PackEntry& entry, std::vector<uint8_t>& r
result.size(),
nullptr,
0);
RecordPackProfileStage(
"m2p",
"plaintext_hash",
result.size(),
plain_hash.size(),
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - hashStart).count()));
if (memcmp(plain_hash.data(), entry.plaintext_hash.data(), plain_hash.size()) != 0)
{

View File

@@ -72,6 +72,7 @@ public:
bool Load(const std::string& path);
const std::vector<TM2PackEntry>& GetIndex() const { return m_index; }
const std::string& GetSourcePath() const { return m_source_path; }
bool GetFile(const TM2PackEntry& entry, std::vector<uint8_t>& result);
bool GetFileWithPool(const TM2PackEntry& entry, std::vector<uint8_t>& result, CBufferPool* pPool);
@@ -82,6 +83,7 @@ private:
private:
TM2PackHeader m_header {};
std::string m_source_path;
std::vector<uint8_t> m_manifest_bytes;
std::vector<TM2PackEntry> m_index;
mio::mmap_source m_file;

View File

@@ -1,6 +1,8 @@
#include "M2PackRuntimeKeyProvider.h"
#include "PackProfile.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <string>
@@ -18,6 +20,7 @@ constexpr const char* M2PACK_ENV_MASTER_KEY = "M2PACK_MASTER_KEY_HEX";
constexpr const char* M2PACK_ENV_PUBLIC_KEY = "M2PACK_SIGN_PUBKEY_HEX";
constexpr const char* M2PACK_ENV_MAP_NAME = "M2PACK_KEY_MAP";
constexpr const char* M2PACK_ENV_KEY_ID = "M2PACK_KEY_ID";
constexpr const char* M2PACK_ENV_STRICT_HASH = "M2PACK_STRICT_HASH";
#pragma pack(push, 1)
struct M2PackSharedKeys
@@ -38,6 +41,7 @@ struct RuntimeKeyState
std::array<uint8_t, M2PACK_PUBLIC_KEY_SIZE> public_key {};
bool runtime_master_key = false;
bool runtime_public_key = false;
bool verify_plaintext_hash = false;
bool initialized = false;
};
@@ -100,6 +104,31 @@ uint32_t ParseUInt32(const std::string& value)
}
}
bool ParseBoolFlag(std::string value, bool& out)
{
value.erase(std::remove_if(value.begin(), value.end(), [](unsigned char ch) {
return std::isspace(ch) != 0;
}), value.end());
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
if (value == "1" || value == "true" || value == "yes" || value == "on")
{
out = true;
return true;
}
if (value == "0" || value == "false" || value == "no" || value == "off")
{
out = false;
return true;
}
return false;
}
const std::array<uint8_t, M2PACK_PUBLIC_KEY_SIZE>* FindCompiledPublicKey(uint32_t keyId)
{
for (std::size_t i = 0; i < M2PACK_SIGN_KEY_IDS.size(); ++i)
@@ -191,16 +220,34 @@ void ApplyCommandLineOption(const std::string& key, const std::string& value)
LoadFromSharedMapping(value);
return;
}
if (key == "--m2pack-strict-hash")
{
bool enabled = false;
if (ParseBoolFlag(value, enabled))
{
g_state.verify_plaintext_hash = enabled;
}
else
{
TraceError("Invalid value for --m2pack-strict-hash");
}
}
}
} // namespace
bool InitializeM2PackRuntimeKeyProvider(const char* commandLine)
{
InitializePackProfile(commandLine);
if (g_state.initialized)
return true;
g_state = RuntimeKeyState {};
#ifdef _DEBUG
g_state.verify_plaintext_hash = true;
#endif
const std::string mapName = GetEnvString(M2PACK_ENV_MAP_NAME);
if (!mapName.empty())
@@ -240,6 +287,16 @@ bool InitializeM2PackRuntimeKeyProvider(const char* commandLine)
TraceError("Invalid M2PACK_SIGN_PUBKEY_HEX value");
}
const std::string envStrictHash = GetEnvString(M2PACK_ENV_STRICT_HASH);
if (!envStrictHash.empty())
{
bool enabled = false;
if (ParseBoolFlag(envStrictHash, enabled))
g_state.verify_plaintext_hash = enabled;
else
TraceError("Invalid M2PACK_STRICT_HASH value");
}
int argc = 0;
PCHAR* argv = CommandLineToArgv(const_cast<PCHAR>(commandLine ? commandLine : ""), &argc);
if (argv)
@@ -247,6 +304,31 @@ bool InitializeM2PackRuntimeKeyProvider(const char* commandLine)
for (int i = 0; i < argc; ++i)
{
const std::string key = argv[i];
if (key == "--m2pack-strict-hash")
{
bool enabled = true;
if (i + 1 < argc)
{
const std::string next = argv[i + 1];
if (!next.starts_with("--"))
{
if (!ParseBoolFlag(next, enabled))
{
TraceError("Invalid value for --m2pack-strict-hash");
}
else
{
g_state.verify_plaintext_hash = enabled;
}
++i;
continue;
}
}
g_state.verify_plaintext_hash = enabled;
continue;
}
if ((key == "--m2pack-key-hex" || key == "--m2pack-pubkey-hex" || key == "--m2pack-key-map" || key == "--m2pack-key-id") && i + 1 < argc)
{
ApplyCommandLineOption(key, argv[i + 1]);
@@ -268,6 +350,11 @@ bool InitializeM2PackRuntimeKeyProvider(const char* commandLine)
Tracef("M2Pack runtime key provider: no runtime master key available; .m2p loading will be denied\n");
}
if (g_state.verify_plaintext_hash)
{
Tracef("M2Pack runtime key provider: plaintext hash verification enabled\n");
}
g_state.initialized = true;
return true;
}
@@ -311,3 +398,8 @@ bool IsM2PackUsingRuntimePublicKey()
{
return g_state.runtime_public_key;
}
bool ShouldVerifyM2PackPlaintextHash()
{
return g_state.verify_plaintext_hash;
}

View File

@@ -12,3 +12,4 @@ bool HasM2PackRuntimeMasterKey();
bool HasM2PackRuntimeKeysForArchiveLoad(uint32_t keyId);
bool IsM2PackUsingRuntimeMasterKey();
bool IsM2PackUsingRuntimePublicKey();
bool ShouldVerifyM2PackPlaintextHash();

View File

@@ -1,5 +1,8 @@
#include "Pack.h"
#include "PackProfile.h"
#include "EterLib/BufferPool.h"
#include <chrono>
#include <zstd.h>
static thread_local ZSTD_DCtx* g_zstdDCtx = nullptr;
@@ -20,6 +23,7 @@ void CPack::DecryptData(uint8_t* data, size_t len, const uint8_t* nonce)
bool CPack::Load(const std::string& path)
{
m_source_path = path;
std::error_code ec;
m_file.map(path, ec);
@@ -68,7 +72,15 @@ bool CPack::GetFileWithPool(const TPackFileEntry& entry, TPackFile& result, CBuf
switch (entry.encryption)
{
case 0: {
const auto decompressStart = std::chrono::steady_clock::now();
size_t decompressed_size = ZSTD_decompressDCtx(dctx, result.data(), result.size(), m_file.data() + offset, entry.compressed_size);
RecordPackProfileStage(
"pck",
"zstd_decompress",
entry.compressed_size,
ZSTD_isError(decompressed_size) ? 0 : decompressed_size,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - decompressStart).count()));
if (decompressed_size != entry.file_size) {
return false;
}
@@ -83,9 +95,25 @@ bool CPack::GetFileWithPool(const TPackFileEntry& entry, TPackFile& result, CBuf
memcpy(compressed_data.data(), m_file.data() + offset, entry.compressed_size);
const auto decryptStart = std::chrono::steady_clock::now();
DecryptData(compressed_data.data(), entry.compressed_size, entry.nonce);
RecordPackProfileStage(
"pck",
"xchacha20_decrypt",
entry.compressed_size,
entry.compressed_size,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - decryptStart).count()));
const auto decompressStart = std::chrono::steady_clock::now();
size_t decompressed_size = ZSTD_decompressDCtx(dctx, result.data(), result.size(), compressed_data.data(), compressed_data.size());
RecordPackProfileStage(
"pck",
"zstd_decompress",
compressed_data.size(),
ZSTD_isError(decompressed_size) ? 0 : decompressed_size,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - decompressStart).count()));
if (pPool) {
pPool->Release(std::move(compressed_data));

View File

@@ -14,6 +14,7 @@ public:
bool Load(const std::string& path);
const std::vector<TPackFileEntry>& GetIndex() const { return m_index; }
const std::string& GetSourcePath() const { return m_source_path; }
bool GetFile(const TPackFileEntry& entry, TPackFile& result);
bool GetFileWithPool(const TPackFileEntry& entry, TPackFile& result, CBufferPool* pPool);
@@ -22,6 +23,7 @@ private:
void DecryptData(uint8_t* data, size_t len, const uint8_t* nonce);
TPackFileHeader m_header;
std::string m_source_path;
std::vector<TPackFileEntry> m_index;
mio::mmap_source m_file;
};

View File

@@ -1,5 +1,8 @@
#include "PackManager.h"
#include "PackProfile.h"
#include "EterLib/BufferPool.h"
#include <chrono>
#include <filesystem>
#include <fstream>
#include <memory>
@@ -27,8 +30,16 @@ bool CPackManager::AddPack(const std::string& path)
if (packPath.extension() == ".m2p")
{
std::shared_ptr<CM2Pack> pack = std::make_shared<CM2Pack>();
const auto loadStart = std::chrono::steady_clock::now();
if (!pack->Load(path))
{
RecordPackProfileMount(
"m2p",
path,
false,
0,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - loadStart).count()));
return false;
}
@@ -38,14 +49,29 @@ bool CPackManager::AddPack(const std::string& path)
{
m_m2_entries[entry.path] = std::make_pair(pack, entry);
}
RecordPackProfileMount(
"m2p",
path,
true,
index.size(),
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - loadStart).count()));
return true;
}
std::shared_ptr<CPack> pack = std::make_shared<CPack>();
const auto loadStart = std::chrono::steady_clock::now();
if (!pack->Load(path))
{
RecordPackProfileMount(
"pck",
path,
false,
0,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - loadStart).count()));
return false;
}
@@ -55,6 +81,13 @@ bool CPackManager::AddPack(const std::string& path)
{
m_entries[entry.file_name] = std::make_pair(pack, entry);
}
RecordPackProfileMount(
"pck",
path,
true,
index.size(),
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - loadStart).count()));
return true;
}
@@ -73,16 +106,37 @@ bool CPackManager::GetFileWithPool(std::string_view path, TPackFile& result, CBu
if (m_load_from_pack) {
auto m2it = m_m2_entries.find(buf);
if (m2it != m_m2_entries.end()) {
return m2it->second.first->GetFileWithPool(m2it->second.second, result, pPool);
const auto loadStart = std::chrono::steady_clock::now();
const bool ok = m2it->second.first->GetFileWithPool(m2it->second.second, result, pPool);
RecordPackProfileLoad(
"m2p",
m2it->second.first->GetSourcePath(),
buf,
ok,
ok ? result.size() : 0,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - loadStart).count()));
return ok;
}
auto it = m_entries.find(buf);
if (it != m_entries.end()) {
return it->second.first->GetFileWithPool(it->second.second, result, pPool);
const auto loadStart = std::chrono::steady_clock::now();
const bool ok = it->second.first->GetFileWithPool(it->second.second, result, pPool);
RecordPackProfileLoad(
"pck",
it->second.first->GetSourcePath(),
buf,
ok,
ok ? result.size() : 0,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - loadStart).count()));
return ok;
}
}
// Fallback to disk (for files not in packs, like bgm folder)
const auto loadStart = std::chrono::steady_clock::now();
std::ifstream ifs(buf, std::ios::binary);
if (ifs.is_open()) {
ifs.seekg(0, std::ios::end);
@@ -97,10 +151,27 @@ bool CPackManager::GetFileWithPool(std::string_view path, TPackFile& result, CBu
}
if (ifs.read((char*)result.data(), size)) {
RecordPackProfileLoad(
"disk",
"<disk>",
buf,
true,
result.size(),
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - loadStart).count()));
return true;
}
}
RecordPackProfileLoad(
"disk",
"<disk>",
buf,
false,
0,
static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - loadStart).count()));
return false;
}

554
src/PackLib/PackProfile.cpp Normal file
View File

@@ -0,0 +1,554 @@
#include "PackProfile.h"
#include <algorithm>
#include <array>
#include <cctype>
#include <chrono>
#include <condition_variable>
#include <filesystem>
#include <fstream>
#include <map>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#include <windows.h>
#include "EterBase/Utils.h"
namespace
{
using Clock = std::chrono::steady_clock;
constexpr const char* kPackProfileEnv = "M2PACK_PROFILE";
constexpr const char* kPackProfileOutput = "log/pack_profile.txt";
constexpr std::size_t kTopListLimit = 12;
constexpr auto kSnapshotFlushInterval = std::chrono::seconds(1);
struct LoadStats
{
std::uint64_t count = 0;
std::uint64_t failures = 0;
std::uint64_t output_bytes = 0;
std::uint64_t elapsed_us = 0;
};
struct MountStats
{
std::uint64_t count = 0;
std::uint64_t failures = 0;
std::uint64_t entry_count = 0;
std::uint64_t elapsed_us = 0;
};
struct StageStats
{
std::uint64_t count = 0;
std::uint64_t input_bytes = 0;
std::uint64_t output_bytes = 0;
std::uint64_t elapsed_us = 0;
};
struct PhaseMarker
{
std::string phase;
std::uint64_t elapsed_us = 0;
};
struct PackProfileState
{
bool initialized = false;
bool enabled = false;
std::string current_phase = "startup";
Clock::time_point start_time {};
std::vector<PhaseMarker> markers;
std::map<std::string, LoadStats> load_by_format;
std::map<std::string, LoadStats> load_by_phase_format;
std::map<std::string, LoadStats> load_by_phase_pack;
std::map<std::string, LoadStats> load_by_extension;
std::map<std::string, MountStats> mount_by_format;
std::map<std::string, MountStats> mount_by_pack;
std::map<std::string, StageStats> stage_by_key;
bool snapshot_dirty = false;
bool stop_snapshot_thread = false;
std::condition_variable snapshot_cv;
std::thread snapshot_thread;
std::mutex mutex;
~PackProfileState()
{
StopSnapshotThread();
if (enabled)
{
std::lock_guard<std::mutex> lock(mutex);
WriteReportLocked("shutdown");
}
}
void StartSnapshotThread()
{
snapshot_thread = std::thread([this]() {
SnapshotLoop();
});
}
void StopSnapshotThread()
{
{
std::lock_guard<std::mutex> lock(mutex);
stop_snapshot_thread = true;
}
snapshot_cv.notify_all();
if (snapshot_thread.joinable())
{
snapshot_thread.join();
}
}
void SnapshotLoop()
{
std::unique_lock<std::mutex> lock(mutex);
for (;;)
{
const bool shouldStop = snapshot_cv.wait_for(
lock,
kSnapshotFlushInterval,
[this]() { return stop_snapshot_thread; });
if (shouldStop)
{
return;
}
if (!enabled || !snapshot_dirty)
{
continue;
}
WriteReportLocked("periodic");
snapshot_dirty = false;
}
}
void WriteReportLocked(std::string_view reason) const
{
std::error_code ec;
std::filesystem::create_directories("log", ec);
std::ofstream output(kPackProfileOutput, std::ios::binary | std::ios::trunc);
if (!output)
{
return;
}
output << "PACK PROFILE REPORT\n";
output << "reason=" << reason << "\n";
output << "current_phase=" << current_phase << "\n";
const auto uptime_us = start_time == Clock::time_point {}
? 0
: static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
Clock::now() - start_time).count());
output << "uptime_ms=" << FormatMs(uptime_us) << "\n\n";
output << "[phase_markers]\n";
for (const auto& marker : markers)
{
output << marker.phase << "\t" << FormatMs(marker.elapsed_us) << " ms\n";
}
output << "\n";
WriteMountSection(output, "[mounts_by_format]", mount_by_format);
WriteMountSection(output, "[mounts_by_pack]", mount_by_pack, kTopListLimit);
WriteLoadSection(output, "[loads_by_format]", load_by_format);
WriteLoadSection(output, "[loads_by_phase_format]", load_by_phase_format);
WriteLoadSection(output, "[top_phase_pack_loads]", load_by_phase_pack, kTopListLimit);
WriteLoadSection(output, "[top_extension_loads]", load_by_extension, kTopListLimit);
WriteStageSection(output, "[loader_stages]", stage_by_key);
}
static std::string FormatMs(std::uint64_t elapsed_us)
{
std::ostringstream out;
out.setf(std::ios::fixed, std::ios::floatfield);
out.precision(3);
out << (static_cast<double>(elapsed_us) / 1000.0);
return out.str();
}
static std::string ToLowerAscii(std::string value)
{
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
static std::string ParsePackLabel(std::string_view pack_path)
{
const std::filesystem::path path { std::string(pack_path) };
const auto filename = path.filename().string();
if (!filename.empty())
{
return ToLowerAscii(filename);
}
return ToLowerAscii(std::string(pack_path));
}
static std::string ParseExtension(std::string_view request_path)
{
const std::filesystem::path path { std::string(request_path) };
auto extension = path.extension().string();
if (extension.empty())
{
return "<none>";
}
return ToLowerAscii(std::move(extension));
}
static std::string MakePhaseKey(std::string_view phase, std::string_view suffix)
{
std::string key;
key.reserve(phase.size() + suffix.size() + 3);
key.append(phase);
key.append(" | ");
key.append(suffix);
return key;
}
template <typename MapType>
static std::vector<std::pair<std::string, typename MapType::mapped_type>> SortByTimeDesc(const MapType& values)
{
using StatsType = typename MapType::mapped_type;
std::vector<std::pair<std::string, StatsType>> sorted;
sorted.reserve(values.size());
for (const auto& [key, stats] : values)
{
sorted.emplace_back(key, stats);
}
std::sort(sorted.begin(), sorted.end(), [](const auto& left, const auto& right) {
if (left.second.elapsed_us != right.second.elapsed_us)
{
return left.second.elapsed_us > right.second.elapsed_us;
}
return left.first < right.first;
});
return sorted;
}
static void WriteLoadSection(std::ofstream& output, std::string_view header, const std::map<std::string, LoadStats>& values, std::size_t limit = 0)
{
output << header << "\n";
const auto sorted = SortByTimeDesc(values);
const std::size_t count = limit == 0 ? sorted.size() : std::min(limit, sorted.size());
for (std::size_t i = 0; i < count; ++i)
{
const auto& [key, stats] = sorted[i];
output
<< key
<< "\tcount=" << stats.count
<< "\tfail=" << stats.failures
<< "\tbytes=" << stats.output_bytes
<< "\ttime_ms=" << FormatMs(stats.elapsed_us)
<< "\n";
}
output << "\n";
}
static void WriteMountSection(std::ofstream& output, std::string_view header, const std::map<std::string, MountStats>& values, std::size_t limit = 0)
{
output << header << "\n";
std::vector<std::pair<std::string, MountStats>> sorted(values.begin(), values.end());
std::sort(sorted.begin(), sorted.end(), [](const auto& left, const auto& right) {
if (left.second.elapsed_us != right.second.elapsed_us)
{
return left.second.elapsed_us > right.second.elapsed_us;
}
return left.first < right.first;
});
const std::size_t count = limit == 0 ? sorted.size() : std::min(limit, sorted.size());
for (std::size_t i = 0; i < count; ++i)
{
const auto& [key, stats] = sorted[i];
output
<< key
<< "\tcount=" << stats.count
<< "\tfail=" << stats.failures
<< "\tentries=" << stats.entry_count
<< "\ttime_ms=" << FormatMs(stats.elapsed_us)
<< "\n";
}
output << "\n";
}
static void WriteStageSection(std::ofstream& output, std::string_view header, const std::map<std::string, StageStats>& values)
{
output << header << "\n";
std::vector<std::pair<std::string, StageStats>> sorted(values.begin(), values.end());
std::sort(sorted.begin(), sorted.end(), [](const auto& left, const auto& right) {
if (left.second.elapsed_us != right.second.elapsed_us)
{
return left.second.elapsed_us > right.second.elapsed_us;
}
return left.first < right.first;
});
for (const auto& [key, stats] : sorted)
{
output
<< key
<< "\tcount=" << stats.count
<< "\tin=" << stats.input_bytes
<< "\tout=" << stats.output_bytes
<< "\ttime_ms=" << FormatMs(stats.elapsed_us)
<< "\n";
}
output << "\n";
}
};
PackProfileState g_pack_profile;
bool ParseBoolFlag(std::string value, bool& out)
{
value.erase(std::remove_if(value.begin(), value.end(), [](unsigned char ch) {
return std::isspace(ch) != 0;
}), value.end());
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
if (value == "1" || value == "true" || value == "yes" || value == "on")
{
out = true;
return true;
}
if (value == "0" || value == "false" || value == "no" || value == "off")
{
out = false;
return true;
}
return false;
}
std::string GetEnvString(const char* name)
{
char buffer[256];
const DWORD len = GetEnvironmentVariableA(name, buffer, sizeof(buffer));
if (len == 0 || len >= sizeof(buffer))
{
return {};
}
return std::string(buffer, buffer + len);
}
std::uint64_t CurrentElapsedUs()
{
if (!g_pack_profile.enabled)
{
return 0;
}
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(
Clock::now() - g_pack_profile.start_time).count());
}
} // namespace
void InitializePackProfile(const char* commandLine)
{
if (g_pack_profile.initialized)
{
return;
}
g_pack_profile.initialized = true;
g_pack_profile.enabled = false;
const std::string envProfile = GetEnvString(kPackProfileEnv);
if (!envProfile.empty())
{
bool enabled = false;
if (ParseBoolFlag(envProfile, enabled))
{
g_pack_profile.enabled = enabled;
}
}
int argc = 0;
PCHAR* argv = CommandLineToArgv(const_cast<PCHAR>(commandLine ? commandLine : ""), &argc);
if (argv)
{
for (int i = 0; i < argc; ++i)
{
const std::string arg = argv[i];
if (arg != "--m2pack-profile")
{
continue;
}
bool enabled = true;
if (i + 1 < argc)
{
const std::string next = argv[i + 1];
if (!next.starts_with("--") && ParseBoolFlag(next, enabled))
{
++i;
}
}
g_pack_profile.enabled = enabled;
}
SAFE_FREE_GLOBAL(argv);
}
if (!g_pack_profile.enabled)
{
return;
}
std::lock_guard<std::mutex> lock(g_pack_profile.mutex);
g_pack_profile.current_phase = "startup";
g_pack_profile.start_time = Clock::now();
g_pack_profile.markers.push_back(PhaseMarker {
.phase = g_pack_profile.current_phase,
.elapsed_us = 0,
});
g_pack_profile.WriteReportLocked("initialized");
g_pack_profile.snapshot_dirty = false;
g_pack_profile.stop_snapshot_thread = false;
g_pack_profile.StartSnapshotThread();
}
bool IsPackProfileEnabled()
{
return g_pack_profile.enabled;
}
void MarkPackProfilePhase(std::string_view phase)
{
if (!g_pack_profile.enabled)
{
return;
}
std::lock_guard<std::mutex> lock(g_pack_profile.mutex);
if (g_pack_profile.current_phase == phase)
{
return;
}
g_pack_profile.current_phase = std::string(phase);
g_pack_profile.markers.push_back(PhaseMarker {
.phase = g_pack_profile.current_phase,
.elapsed_us = CurrentElapsedUs(),
});
g_pack_profile.WriteReportLocked(PackProfileState::MakePhaseKey("phase", phase));
g_pack_profile.snapshot_dirty = false;
}
void RecordPackProfileMount(
std::string_view format,
std::string_view packPath,
bool ok,
std::size_t entryCount,
std::uint64_t elapsedUs)
{
if (!g_pack_profile.enabled)
{
return;
}
std::lock_guard<std::mutex> lock(g_pack_profile.mutex);
auto& formatStats = g_pack_profile.mount_by_format[std::string(format)];
formatStats.count += 1;
formatStats.failures += ok ? 0 : 1;
formatStats.entry_count += entryCount;
formatStats.elapsed_us += elapsedUs;
auto& packStats = g_pack_profile.mount_by_pack[PackProfileState::ParsePackLabel(packPath)];
packStats.count += 1;
packStats.failures += ok ? 0 : 1;
packStats.entry_count += entryCount;
packStats.elapsed_us += elapsedUs;
g_pack_profile.snapshot_dirty = true;
}
void RecordPackProfileLoad(
std::string_view format,
std::string_view packPath,
std::string_view requestPath,
bool ok,
std::uint64_t outputBytes,
std::uint64_t elapsedUs)
{
if (!g_pack_profile.enabled)
{
return;
}
std::lock_guard<std::mutex> lock(g_pack_profile.mutex);
auto update = [ok, outputBytes, elapsedUs](LoadStats& stats) {
stats.count += 1;
stats.failures += ok ? 0 : 1;
stats.output_bytes += outputBytes;
stats.elapsed_us += elapsedUs;
};
update(g_pack_profile.load_by_format[std::string(format)]);
update(g_pack_profile.load_by_phase_format[
PackProfileState::MakePhaseKey(g_pack_profile.current_phase, format)]);
update(g_pack_profile.load_by_phase_pack[
PackProfileState::MakePhaseKey(g_pack_profile.current_phase, PackProfileState::ParsePackLabel(packPath))]);
update(g_pack_profile.load_by_extension[PackProfileState::ParseExtension(requestPath)]);
g_pack_profile.snapshot_dirty = true;
}
void RecordPackProfileStage(
std::string_view format,
std::string_view stage,
std::uint64_t inputBytes,
std::uint64_t outputBytes,
std::uint64_t elapsedUs)
{
if (!g_pack_profile.enabled)
{
return;
}
std::lock_guard<std::mutex> lock(g_pack_profile.mutex);
auto& stats = g_pack_profile.stage_by_key[
PackProfileState::MakePhaseKey(format, stage)];
stats.count += 1;
stats.input_bytes += inputBytes;
stats.output_bytes += outputBytes;
stats.elapsed_us += elapsedUs;
g_pack_profile.snapshot_dirty = true;
}
void FlushPackProfileSnapshot(std::string_view reason)
{
if (!g_pack_profile.enabled)
{
return;
}
std::lock_guard<std::mutex> lock(g_pack_profile.mutex);
g_pack_profile.WriteReportLocked(reason);
g_pack_profile.snapshot_dirty = false;
}

29
src/PackLib/PackProfile.h Normal file
View File

@@ -0,0 +1,29 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string_view>
void InitializePackProfile(const char* commandLine);
bool IsPackProfileEnabled();
void MarkPackProfilePhase(std::string_view phase);
void RecordPackProfileMount(
std::string_view format,
std::string_view packPath,
bool ok,
std::size_t entryCount,
std::uint64_t elapsedUs);
void RecordPackProfileLoad(
std::string_view format,
std::string_view packPath,
std::string_view requestPath,
bool ok,
std::uint64_t outputBytes,
std::uint64_t elapsedUs);
void RecordPackProfileStage(
std::string_view format,
std::string_view stage,
std::uint64_t inputBytes,
std::uint64_t outputBytes,
std::uint64_t elapsedUs);
void FlushPackProfileSnapshot(std::string_view reason);

View File

@@ -245,6 +245,15 @@ void CInstanceBase::SHORSE::Render()
rkActor.Render();
}
void CInstanceBase::SHORSE::ApplyRenderInterpolation(float fInterpolation)
{
if (!IsMounting())
return;
CActorInstance& rkActor = GetActorRef();
rkActor.ApplyRenderInterpolation(fInterpolation);
}
void CInstanceBase::__AttachHorseSaddle()
{
if (!IsMountingHorse())
@@ -1948,6 +1957,12 @@ void CInstanceBase::Transform()
m_GraphicThingInstance.INSTANCEBASE_Transform();
}
void CInstanceBase::ApplyRenderInterpolation(float fInterpolation)
{
m_kHorse.ApplyRenderInterpolation(fInterpolation);
m_GraphicThingInstance.ApplyRenderInterpolation(fInterpolation);
}
void CInstanceBase::Deform()
{

View File

@@ -472,6 +472,7 @@ class CInstanceBase
bool UpdateDeleting();
void Transform();
void ApplyRenderInterpolation(float fInterpolation);
void Deform();
void Render();
void RenderTrace();
@@ -847,6 +848,7 @@ class CInstanceBase
void SetMoveSpeed(UINT uMovSpd);
void Deform();
void Render();
void ApplyRenderInterpolation(float fInterpolation);
CActorInstance& GetActorRef();
CActorInstance* GetActorPtr();
@@ -1139,4 +1141,4 @@ inline int RaceToSex(int race)
}
return 0;
}
}

View File

@@ -6,12 +6,15 @@
#include "EterGrnLib/Material.h"
#include "resource.h"
#include "GameType.h"
#include "PythonApplication.h"
#include "PythonCharacterManager.h"
#include "ProcessScanner.h"
#include <utf8.h>
#include <cstdarg>
#include <cstdlib>
extern void GrannyCreateSharedDeformBuffer();
extern void GrannyDestroySharedDeformBuffer();
@@ -21,6 +24,91 @@ double g_specularSpd=0.007f;
CPythonApplication * CPythonApplication::ms_pInstance;
namespace
{
// Match the legacy custom timer cadence of alternating 17 ms and 16 ms.
constexpr double kFixedUpdateMS = 16.5;
constexpr double kMaxCatchUpDelayMS = 250.0;
float ClampInterpolationFactor(float fInterpolation)
{
return std::max(0.0f, std::min(1.0f, fInterpolation));
}
D3DXVECTOR3 LerpVector3(const D3DXVECTOR3& c_rv3Begin, const D3DXVECTOR3& c_rv3End, float fInterpolation)
{
return D3DXVECTOR3(
c_rv3Begin.x + (c_rv3End.x - c_rv3Begin.x) * fInterpolation,
c_rv3Begin.y + (c_rv3End.y - c_rv3Begin.y) * fInterpolation,
c_rv3Begin.z + (c_rv3End.z - c_rv3Begin.z) * fInterpolation);
}
bool IsRenderTelemetryEnabledFromEnvironment()
{
const char* c_szValue = std::getenv("M2_RENDER_TELEMETRY");
if (!c_szValue || !*c_szValue)
return false;
if (0 == _stricmp(c_szValue, "1") || 0 == _stricmp(c_szValue, "true") || 0 == _stricmp(c_szValue, "yes") || 0 == _stricmp(c_szValue, "on"))
return true;
return false;
}
bool TryReadEnvironmentInt(const char* c_szName, int* piValue)
{
if (!piValue)
return false;
const char* c_szValue = std::getenv(c_szName);
if (!c_szValue || !*c_szValue)
return false;
char* pEnd = nullptr;
const long lValue = std::strtol(c_szValue, &pEnd, 10);
if (pEnd == c_szValue || (pEnd && *pEnd))
return false;
*piValue = static_cast<int>(lValue);
return true;
}
void ResetRenderTelemetryTrace()
{
FILE* fp = nullptr;
if (fopen_s(&fp, "log/render_telemetry.txt", "w") != 0 || !fp)
return;
fprintf(fp, "render telemetry\n");
fclose(fp);
}
void AppendRenderTelemetryTrace(const char* c_szFormat, ...)
{
FILE* fp = nullptr;
if (fopen_s(&fp, "log/render_telemetry.txt", "a") != 0 || !fp)
return;
va_list args;
va_start(args, c_szFormat);
vfprintf(fp, c_szFormat, args);
va_end(args);
fputc('\n', fp);
fclose(fp);
}
void FormatRenderTargetFPSLabel(unsigned int iFPS, char* c_szBuffer, size_t uBufferSize)
{
if (!c_szBuffer || 0 == uBufferSize)
return;
if (iFPS > 0)
_snprintf_s(c_szBuffer, uBufferSize, _TRUNCATE, "%u", iFPS);
else
_snprintf_s(c_szBuffer, uBufferSize, _TRUNCATE, "MAX");
}
}
float c_fDefaultCameraRotateSpeed = 1.5f;
float c_fDefaultCameraPitchSpeed = 1.5f;
float c_fDefaultCameraZoomSpeed = 0.05f;
@@ -35,9 +123,28 @@ m_poMouseHandler(NULL),
m_dwUpdateFPS(0),
m_dwRenderFPS(0),
m_fAveRenderTime(0.0f),
m_bRenderTelemetryEnabled(false),
m_bRenderTelemetryHudVisible(false),
m_dwRenderTelemetryIntervalMS(1000),
m_dwRenderTelemetryWindowStartMS(0),
m_dwRenderTelemetryLoopCount(0),
m_dwRenderTelemetryUpdateCount(0),
m_dwRenderTelemetryRenderCount(0),
m_dwRenderTelemetryBlockedRenderCount(0),
m_dwRenderTelemetryLastPresentTime(0),
m_dwRenderTelemetryPresentGapSamples(0),
m_fRenderTelemetryUpdateTimeSumMS(0.0),
m_fRenderTelemetryRenderTimeSumMS(0.0),
m_fRenderTelemetrySleepTimeSumMS(0.0),
m_fRenderTelemetryInterpolationSum(0.0),
m_fRenderTelemetryPresentGapSumMS(0.0),
m_dwCurRenderTime(0),
m_dwCurUpdateTime(0),
m_dwLoad(0),
m_dwFaceCount(0),
m_fGlobalTime(0.0f),
m_fGlobalElapsedTime(0.0f),
m_fRenderInterpolationFactor(0.0f),
m_dwLButtonDownTime(0),
m_dwLastIdleTime(0),
m_IsMovingMainWindow(false)
@@ -54,12 +161,15 @@ m_IsMovingMainWindow(false)
m_isWindowFullScreenEnable = FALSE;
m_v3CenterPosition = D3DXVECTOR3(0.0f, 0.0f, 0.0f);
m_v3LastCenterPosition = m_v3CenterPosition;
m_dwStartLocalTime = ELTimer_GetMSec();
m_tServerTime = 0;
m_tLocalStartTime = 0;
m_iPort = 0;
m_iFPS = 60;
m_iFPS = std::max(0, m_pySystem.GetRenderFPS());
m_bRenderTelemetryHudVisible = m_pySystem.IsShowPerformanceHUD();
InitializeRenderRuntimeOverrides();
m_isActivateWnd = false;
m_isMinimizedWnd = true;
@@ -168,6 +278,7 @@ void CPythonApplication::RenderGame()
float fFarClip = m_pyBackground.GetFarClip();
m_pyGraphic.SetPerspective(30.0f, fAspect, 100.0, fFarClip);
ApplyRenderInterpolation();
CCullingManager::Instance().Process();
@@ -256,10 +367,137 @@ void CPythonApplication::UpdateGame()
SetCenterPosition(kPPosMainActor.x, kPPosMainActor.y, kPPosMainActor.z);
}
void CPythonApplication::InitializeRenderRuntimeOverrides()
{
m_bRenderTelemetryEnabled = IsRenderTelemetryEnabledFromEnvironment();
int iIntervalMS = 0;
if (TryReadEnvironmentInt("M2_RENDER_TELEMETRY_INTERVAL_MS", &iIntervalMS) && iIntervalMS > 0)
m_dwRenderTelemetryIntervalMS = static_cast<DWORD>(std::min(iIntervalMS, 60000));
if (m_bRenderTelemetryEnabled)
{
ResetRenderTelemetryTrace();
AppendRenderTelemetryTrace("enabled interval_ms=%lu", static_cast<unsigned long>(m_dwRenderTelemetryIntervalMS));
}
int iRenderFPS = 0;
if (TryReadEnvironmentInt("M2_RENDER_FPS", &iRenderFPS))
{
m_iFPS = std::max(0, iRenderFPS);
if (m_bRenderTelemetryEnabled)
AppendRenderTelemetryTrace("env_override target_fps=%u", m_iFPS);
}
char szTargetFPS[16];
FormatRenderTargetFPSLabel(m_iFPS, szTargetFPS, sizeof(szTargetFPS));
m_stRenderTelemetrySummary = "Render telemetry\nTarget FPS: ";
m_stRenderTelemetrySummary += szTargetFPS;
m_stRenderTelemetrySummary += "\nCollecting frame pacing...";
if (IsRenderTelemetrySamplingEnabled())
ResetRenderTelemetryWindow(ELTimer_GetMSec());
}
void CPythonApplication::ResetRenderTelemetryWindow(DWORD dwNow)
{
m_dwRenderTelemetryWindowStartMS = dwNow;
m_dwRenderTelemetryLoopCount = 0;
m_dwRenderTelemetryUpdateCount = 0;
m_dwRenderTelemetryRenderCount = 0;
m_dwRenderTelemetryBlockedRenderCount = 0;
m_dwRenderTelemetryPresentGapSamples = 0;
m_fRenderTelemetryUpdateTimeSumMS = 0.0;
m_fRenderTelemetryRenderTimeSumMS = 0.0;
m_fRenderTelemetrySleepTimeSumMS = 0.0;
m_fRenderTelemetryInterpolationSum = 0.0;
m_fRenderTelemetryPresentGapSumMS = 0.0;
}
void CPythonApplication::FlushRenderTelemetryWindow(DWORD dwNow)
{
if (!IsRenderTelemetrySamplingEnabled() || !m_dwRenderTelemetryWindowStartMS)
return;
const DWORD dwWindowMS = std::max<DWORD>(1, dwNow - m_dwRenderTelemetryWindowStartMS);
const double fWindowMS = static_cast<double>(dwWindowMS);
const double fUpdateFPS = static_cast<double>(m_dwRenderTelemetryUpdateCount) * 1000.0 / fWindowMS;
const double fRenderFPS = static_cast<double>(m_dwRenderTelemetryRenderCount) * 1000.0 / fWindowMS;
const double fAvgUpdateMS = m_dwRenderTelemetryUpdateCount ? (m_fRenderTelemetryUpdateTimeSumMS / static_cast<double>(m_dwRenderTelemetryUpdateCount)) : 0.0;
const double fAvgRenderMS = m_dwRenderTelemetryRenderCount ? (m_fRenderTelemetryRenderTimeSumMS / static_cast<double>(m_dwRenderTelemetryRenderCount)) : 0.0;
const double fAvgSleepMS = m_dwRenderTelemetryLoopCount ? (m_fRenderTelemetrySleepTimeSumMS / static_cast<double>(m_dwRenderTelemetryLoopCount)) : 0.0;
const double fAvgInterpolation = m_dwRenderTelemetryRenderCount ? (m_fRenderTelemetryInterpolationSum / static_cast<double>(m_dwRenderTelemetryRenderCount)) : 0.0;
const double fAvgPresentGapMS = m_dwRenderTelemetryPresentGapSamples ? (m_fRenderTelemetryPresentGapSumMS / static_cast<double>(m_dwRenderTelemetryPresentGapSamples)) : 0.0;
const DWORD dwElapsedMS = dwNow - m_dwStartLocalTime;
char szTargetFPS[16];
char szSummary[256];
FormatRenderTargetFPSLabel(m_iFPS, szTargetFPS, sizeof(szTargetFPS));
_snprintf_s(
szSummary,
sizeof(szSummary),
_TRUNCATE,
"Render %.1f Update %.1f Target %s\nPresent %.1fms Sleep %.1fms CPU %.1f/%.1fms",
fRenderFPS,
fUpdateFPS,
szTargetFPS,
fAvgPresentGapMS,
fAvgSleepMS,
fAvgUpdateMS,
fAvgRenderMS);
m_stRenderTelemetrySummary = szSummary;
if (m_bRenderTelemetryEnabled)
{
AppendRenderTelemetryTrace(
"elapsed_ms=%lu window_ms=%lu target_fps=%u update_fps=%.2f render_fps=%.2f avg_update_ms=%.2f avg_render_ms=%.2f avg_sleep_ms=%.2f avg_present_gap_ms=%.2f avg_interp=%.3f loops=%lu updates=%lu renders=%lu blocked=%lu cur_update_ms=%lu cur_render_ms=%lu",
static_cast<unsigned long>(dwElapsedMS),
static_cast<unsigned long>(dwWindowMS),
m_iFPS,
fUpdateFPS,
fRenderFPS,
fAvgUpdateMS,
fAvgRenderMS,
fAvgSleepMS,
fAvgPresentGapMS,
fAvgInterpolation,
static_cast<unsigned long>(m_dwRenderTelemetryLoopCount),
static_cast<unsigned long>(m_dwRenderTelemetryUpdateCount),
static_cast<unsigned long>(m_dwRenderTelemetryRenderCount),
static_cast<unsigned long>(m_dwRenderTelemetryBlockedRenderCount),
static_cast<unsigned long>(m_dwCurUpdateTime),
static_cast<unsigned long>(m_dwCurRenderTime));
}
ResetRenderTelemetryWindow(dwNow);
}
bool CPythonApplication::IsRenderTelemetrySamplingEnabled() const
{
return m_bRenderTelemetryEnabled || m_bRenderTelemetryHudVisible;
}
void CPythonApplication::RenderPerformanceHUD()
{
if (!m_bRenderTelemetryHudVisible)
return;
CGraphicText* pkDefaultFont = static_cast<CGraphicText*>(DefaultFont_GetResource());
if (!pkDefaultFont)
return;
m_kRenderTelemetryTextLine.SetTextPointer(pkDefaultFont);
m_kRenderTelemetryTextLine.SetOutline(true);
m_kRenderTelemetryTextLine.SetMultiLine(true);
m_kRenderTelemetryTextLine.SetColor(1.0f, 1.0f, 1.0f);
m_kRenderTelemetryTextLine.SetPosition(12.0f, 12.0f);
m_kRenderTelemetryTextLine.SetValueString(m_stRenderTelemetrySummary);
m_kRenderTelemetryTextLine.Update();
m_kRenderTelemetryTextLine.Render();
}
bool CPythonApplication::Process()
{
ELTimer_SetFrameMSec();
// m_Profiler.Clear();
DWORD dwStart = ELTimer_GetMSec();
@@ -269,6 +507,18 @@ bool CPythonApplication::Process()
static DWORD s_dwFaceCount = 0;
static UINT s_uiLoad = 0;
static DWORD s_dwCheckTime = ELTimer_GetMSec();
static double s_fNextUpdateTime = static_cast<double>(ELTimer_GetMSec());
static double s_fNextRenderTime = static_cast<double>(ELTimer_GetMSec());
static double s_fFixedFrameTime = static_cast<double>(ELTimer_GetMSec());
DWORD dwCurrentTime = ELTimer_GetMSec();
const double fCurrentTime = static_cast<double>(dwCurrentTime);
const bool bSampleRenderTelemetry = IsRenderTelemetrySamplingEnabled();
if (bSampleRenderTelemetry && !m_dwRenderTelemetryWindowStartMS)
ResetRenderTelemetryWindow(dwCurrentTime);
if (bSampleRenderTelemetry)
++m_dwRenderTelemetryLoopCount;
if (ELTimer_GetMSec() - s_dwCheckTime > 1000) [[unlikely]] {
m_dwUpdateFPS = s_dwUpdateFrameCount;
@@ -282,278 +532,314 @@ bool CPythonApplication::Process()
s_uiLoad = s_dwFaceCount = s_dwUpdateFrameCount = s_dwRenderFrameCount = 0;
}
// Update Time
static BOOL s_bFrameSkip = false;
static UINT s_uiNextFrameTime = ELTimer_GetMSec();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime1=ELTimer_GetMSec();
#endif
CTimer& rkTimer=CTimer::Instance();
rkTimer.Advance();
m_fGlobalTime = rkTimer.GetCurrentSecond();
m_fGlobalElapsedTime = rkTimer.GetElapsedSecond();
UINT uiFrameTime = rkTimer.GetElapsedMilliecond();
s_uiNextFrameTime += uiFrameTime; //17 - 1ÃÊ´ç 60fps±âÁØ.
DWORD updatestart = ELTimer_GetMSec();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime2=ELTimer_GetMSec();
#endif
// Network I/O
m_pyNetworkStream.Process();
//m_pyNetworkDatagram.Process();
m_kGuildMarkUploader.Process();
m_kGuildMarkDownloader.Process();
m_kAccountConnector.Process();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime3=ELTimer_GetMSec();
#endif
//////////////////////
// Input Process
// Keyboard
UpdateKeyboard();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime4=ELTimer_GetMSec();
#endif
// Mouse
POINT Point;
if (GetCursorPos(&Point)) [[likely]] {
ScreenToClient(m_hWnd, &Point);
OnMouseMove(Point.x, Point.y);
}
//////////////////////
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime5=ELTimer_GetMSec();
#endif
//!@# Alt+Tab Áß SetTransfor ¿¡¼­ ƨ±è Çö»ó ÇØ°áÀ» À§ÇØ - [levites]
//if (m_isActivateWnd)
__UpdateCamera();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime6=ELTimer_GetMSec();
#endif
// Update Game Playing
CResourceManager::Instance().Update();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime7=ELTimer_GetMSec();
#endif
OnCameraUpdate();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime8=ELTimer_GetMSec();
#endif
OnMouseUpdate();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime9=ELTimer_GetMSec();
#endif
OnUIUpdate();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime10=ELTimer_GetMSec();
if (dwUpdateTime10-dwUpdateTime1>10)
{
static FILE* fp=fopen("perf_app_update.txt", "w");
fprintf(fp, "AU.Total %d (Time %d)\n", dwUpdateTime9-dwUpdateTime1, ELTimer_GetMSec());
fprintf(fp, "AU.TU %d\n", dwUpdateTime2-dwUpdateTime1);
fprintf(fp, "AU.NU %d\n", dwUpdateTime3-dwUpdateTime2);
fprintf(fp, "AU.KU %d\n", dwUpdateTime4-dwUpdateTime3);
fprintf(fp, "AU.MP %d\n", dwUpdateTime5-dwUpdateTime4);
fprintf(fp, "AU.CP %d\n", dwUpdateTime6-dwUpdateTime5);
fprintf(fp, "AU.RU %d\n", dwUpdateTime7-dwUpdateTime6);
fprintf(fp, "AU.CU %d\n", dwUpdateTime8-dwUpdateTime7);
fprintf(fp, "AU.MU %d\n", dwUpdateTime9-dwUpdateTime8);
fprintf(fp, "AU.UU %d\n", dwUpdateTime10-dwUpdateTime9);
fprintf(fp, "----------------------------------\n");
fflush(fp);
}
#endif
//UpdateÇϴµ¥ °É¸°½Ã°£.delta°ª
m_dwCurUpdateTime = ELTimer_GetMSec() - updatestart;
DWORD dwCurrentTime = ELTimer_GetMSec();
BOOL bCurrentLateUpdate = FALSE;
s_bFrameSkip = false;
if (dwCurrentTime > s_uiNextFrameTime)
if (fCurrentTime - s_fNextUpdateTime > kMaxCatchUpDelayMS)
{
int dt = dwCurrentTime - s_uiNextFrameTime;
int nAdjustTime = ((float)dt / (float)uiFrameTime) * uiFrameTime;
s_fNextUpdateTime = fCurrentTime;
s_fFixedFrameTime = fCurrentTime;
}
if ( dt >= 500 )
if (fCurrentTime - s_fNextRenderTime > kMaxCatchUpDelayMS)
s_fNextRenderTime = fCurrentTime;
int iUpdateCount = 0;
while (fCurrentTime + 0.0001 >= s_fNextUpdateTime)
{
if (!m_isFrameSkipDisable && iUpdateCount >= 5)
{
s_uiNextFrameTime += nAdjustTime;
printf("FrameSkip º¸Á¤ %d\n",nAdjustTime);
CTimer::Instance().Adjust(nAdjustTime);
s_fNextUpdateTime = fCurrentTime;
s_fFixedFrameTime = fCurrentTime;
break;
}
s_bFrameSkip = true;
bCurrentLateUpdate = TRUE;
}
++iUpdateCount;
s_fNextUpdateTime += kFixedUpdateMS;
s_fFixedFrameTime += kFixedUpdateMS;
ELTimer_SetFrameMSecValue(static_cast<DWORD>(s_fFixedFrameTime));
m_v3LastCenterPosition = m_v3CenterPosition;
//s_bFrameSkip = false;
//if (dwCurrentTime > s_uiNextFrameTime)
//{
// int dt = dwCurrentTime - s_uiNextFrameTime;
// //³Ê¹« ´Ê¾úÀ» °æ¿ì µû¶óÀâ´Â´Ù.
// //±×¸®°í m_dwCurUpdateTime´Â deltaÀε¥ delta¶û absolute timeÀ̶û ºñ±³ÇÏ¸é ¾î¼Àڴ°Ü?
// //if (dt >= 500 || m_dwCurUpdateTime > s_uiNextFrameTime)
// //±âÁ¸ÄÚµå´ë·Î Çϸé 0.5ÃÊ ÀÌÇÏ Â÷À̳­ »óÅ·Πupdate°¡ Áö¼ÓµÇ¸é °è¼Ó rendering frame skip¹ß»ý
// if (dt >= 500 || m_dwCurUpdateTime > s_uiNextFrameTime)
// {
// s_uiNextFrameTime += dt / uiFrameTime * uiFrameTime;
// printf("FrameSkip º¸Á¤ %d\n", dt / uiFrameTime * uiFrameTime);
// CTimer::Instance().Adjust((dt / uiFrameTime) * uiFrameTime);
// s_bFrameSkip = true;
// }
//}
if (m_isFrameSkipDisable)
s_bFrameSkip = false;
#ifdef __VTUNE__
s_bFrameSkip = false;
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime1=ELTimer_GetMSec();
#endif
if (!s_bFrameSkip)
{
// static double pos=0.0f;
// CGrannyMaterial::TranslateSpecularMatrix(fabs(sin(pos)*0.005), fabs(cos(pos)*0.005), 0.0f);
// pos+=0.01f;
CTimer& rkTimer=CTimer::Instance();
rkTimer.Advance();
m_fGlobalTime = rkTimer.GetCurrentSecond();
m_fGlobalElapsedTime = rkTimer.GetElapsedSecond();
DWORD updatestart = ELTimer_GetMSec();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime2=ELTimer_GetMSec();
#endif
// Network I/O
m_pyNetworkStream.Process();
//m_pyNetworkDatagram.Process();
m_kGuildMarkUploader.Process();
m_kGuildMarkDownloader.Process();
m_kAccountConnector.Process();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime3=ELTimer_GetMSec();
#endif
//////////////////////
// Input Process
// Keyboard
UpdateKeyboard();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime4=ELTimer_GetMSec();
#endif
// Mouse
POINT Point;
if (GetCursorPos(&Point)) [[likely]] {
ScreenToClient(m_hWnd, &Point);
OnMouseMove(Point.x, Point.y);
}
//////////////////////
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime5=ELTimer_GetMSec();
#endif
//!@# Alt+Tab Áß SetTransfor ¿¡¼­ ƨ±è Çö»ó ÇØ°áÀ» À§ÇØ - [levites]
//if (m_isActivateWnd)
__UpdateCamera();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime6=ELTimer_GetMSec();
#endif
// Update Game Playing
CResourceManager::Instance().Update();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime7=ELTimer_GetMSec();
#endif
OnCameraUpdate();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime8=ELTimer_GetMSec();
#endif
OnMouseUpdate();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime9=ELTimer_GetMSec();
#endif
CGrannyMaterial::TranslateSpecularMatrix(g_specularSpd, g_specularSpd, 0.0f);
OnUIUpdate();
DWORD dwRenderStartTime = ELTimer_GetMSec();
#ifdef __PERFORMANCE_CHECK__
DWORD dwUpdateTime10=ELTimer_GetMSec();
bool canRender = true;
if (m_isMinimizedWnd) [[unlikely]] {
canRender = false;
}
else [[likely]] {
if (m_pyGraphic.IsLostDevice()) [[unlikely]] {
CPythonBackground& rkBG = CPythonBackground::Instance();
rkBG.ReleaseCharacterShadowTexture();
if (m_pyGraphic.RestoreDevice())
rkBG.CreateCharacterShadowTexture();
else
canRender = false;
}
}
if (canRender) [[likely]]
if (dwUpdateTime10-dwUpdateTime1>10)
{
// RestoreLostDevice
CCullingManager::Instance().Update();
if (m_pyGraphic.Begin()) [[likely]] {
static FILE* fp=fopen("perf_app_update.txt", "w");
m_pyGraphic.ClearDepthBuffer();
fprintf(fp, "AU.Total %d (Time %d)\n", dwUpdateTime9-dwUpdateTime1, ELTimer_GetMSec());
fprintf(fp, "AU.TU %d\n", dwUpdateTime2-dwUpdateTime1);
fprintf(fp, "AU.NU %d\n", dwUpdateTime3-dwUpdateTime2);
fprintf(fp, "AU.KU %d\n", dwUpdateTime4-dwUpdateTime3);
fprintf(fp, "AU.MP %d\n", dwUpdateTime5-dwUpdateTime4);
fprintf(fp, "AU.CP %d\n", dwUpdateTime6-dwUpdateTime5);
fprintf(fp, "AU.RU %d\n", dwUpdateTime7-dwUpdateTime6);
fprintf(fp, "AU.CU %d\n", dwUpdateTime8-dwUpdateTime7);
fprintf(fp, "AU.MU %d\n", dwUpdateTime9-dwUpdateTime8);
fprintf(fp, "AU.UU %d\n", dwUpdateTime10-dwUpdateTime9);
fprintf(fp, "----------------------------------\n");
fflush(fp);
}
#endif
//UpdateÇϴµ¥ °É¸°½Ã°£.delta°ª
m_dwCurUpdateTime = ELTimer_GetMSec() - updatestart;
if (bSampleRenderTelemetry)
{
++m_dwRenderTelemetryUpdateCount;
m_fRenderTelemetryUpdateTimeSumMS += static_cast<double>(m_dwCurUpdateTime);
}
++s_dwUpdateFrameCount;
}
const double fPreviousUpdateTime = s_fNextUpdateTime - kFixedUpdateMS;
m_fRenderInterpolationFactor = ClampInterpolationFactor(static_cast<float>((fCurrentTime - fPreviousUpdateTime) / kFixedUpdateMS));
DWORD dwRenderStartTime = ELTimer_GetMSec();
bool canRender = true;
bool didRender = false;
if (m_isMinimizedWnd) [[unlikely]] {
canRender = false;
}
else [[likely]] {
if (m_pyGraphic.IsLostDevice()) [[unlikely]] {
CPythonBackground& rkBG = CPythonBackground::Instance();
rkBG.ReleaseCharacterShadowTexture();
if (m_pyGraphic.RestoreDevice())
rkBG.CreateCharacterShadowTexture();
else
canRender = false;
}
}
if (canRender) [[likely]]
{
// RestoreLostDevice
CCullingManager::Instance().Update();
if (m_pyGraphic.Begin()) [[likely]] {
m_pyGraphic.ClearDepthBuffer();
#ifdef _DEBUG
m_pyGraphic.SetClearColor(0.3f, 0.3f, 0.3f);
m_pyGraphic.Clear();
m_pyGraphic.SetClearColor(0.3f, 0.3f, 0.3f);
m_pyGraphic.Clear();
#endif
/////////////////////
// Interface
m_pyGraphic.SetInterfaceRenderState();
/////////////////////
// Interface
m_pyGraphic.SetInterfaceRenderState();
OnUIRender();
OnMouseRender();
/////////////////////
OnUIRender();
RenderPerformanceHUD();
OnMouseRender();
/////////////////////
m_pyGraphic.End();
m_pyGraphic.End();
m_pyGraphic.Show();
//DWORD t1 = ELTimer_GetMSec();
m_pyGraphic.Show();
//DWORD t2 = ELTimer_GetMSec();
DWORD dwRenderEndTime = ELTimer_GetMSec();
didRender = true;
DWORD dwRenderEndTime = ELTimer_GetMSec();
static DWORD s_dwRenderCheckTime = dwRenderEndTime;
static DWORD s_dwRenderRangeTime = 0;
static DWORD s_dwRenderRangeFrame = 0;
static DWORD s_dwRenderCheckTime = dwRenderEndTime;
static DWORD s_dwRenderRangeTime = 0;
static DWORD s_dwRenderRangeFrame = 0;
m_dwCurRenderTime = dwRenderEndTime - dwRenderStartTime;
s_dwRenderRangeTime += m_dwCurRenderTime;
++s_dwRenderRangeFrame;
m_dwCurRenderTime = dwRenderEndTime - dwRenderStartTime;
s_dwRenderRangeTime += m_dwCurRenderTime;
++s_dwRenderRangeFrame;
if (dwRenderEndTime-s_dwRenderCheckTime>1000) [[unlikely]] {
m_fAveRenderTime=float(double(s_dwRenderRangeTime)/double(s_dwRenderRangeFrame));
if (dwRenderEndTime-s_dwRenderCheckTime>1000) [[unlikely]] {
m_fAveRenderTime=float(double(s_dwRenderRangeTime)/double(s_dwRenderRangeFrame));
s_dwRenderCheckTime=ELTimer_GetMSec();
s_dwRenderRangeTime=0;
s_dwRenderRangeFrame=0;
}
s_dwRenderCheckTime=ELTimer_GetMSec();
s_dwRenderRangeTime=0;
s_dwRenderRangeFrame=0;
}
DWORD dwCurFaceCount=m_pyGraphic.GetFaceCount();
m_pyGraphic.ResetFaceCount();
s_dwFaceCount += dwCurFaceCount;
DWORD dwCurFaceCount=m_pyGraphic.GetFaceCount();
m_pyGraphic.ResetFaceCount();
s_dwFaceCount += dwCurFaceCount;
if (dwCurFaceCount > 5000)
{
m_dwFaceAccCount += dwCurFaceCount;
m_dwFaceAccTime += m_dwCurRenderTime;
if (dwCurFaceCount > 5000)
m_fFaceSpd=(m_dwFaceAccCount/m_dwFaceAccTime);
// °Å¸® ÀÚµ¿ Á¶Àý
if (-1 == m_iForceSightRange)
{
m_dwFaceAccCount += dwCurFaceCount;
m_dwFaceAccTime += m_dwCurRenderTime;
m_fFaceSpd=(m_dwFaceAccCount/m_dwFaceAccTime);
// °Å¸® ÀÚµ¿ Á¶Àý
if (-1 == m_iForceSightRange)
{
static float s_fAveRenderTime = 16.0f;
float fRatio=0.3f;
s_fAveRenderTime=(s_fAveRenderTime*(100.0f-fRatio)+std::max(16.0f, (float)m_dwCurRenderTime)*fRatio)/100.0f;
static float s_fAveRenderTime = 16.0f;
float fRatio=0.3f;
s_fAveRenderTime=(s_fAveRenderTime*(100.0f-fRatio)+std::max(16.0f, (float)m_dwCurRenderTime)*fRatio)/100.0f;
float fFar=25600.0f;
float fNear=MIN_FOG;
double dbAvePow=double(1000.0f/s_fAveRenderTime);
double dbMaxPow=60.0;
float fDistance=std::max((float)(fNear+(fFar-fNear)*(dbAvePow)/dbMaxPow), fNear);
m_pyBackground.SetViewDistanceSet(0, fDistance);
}
// °Å¸® °­Á¦ ¼³Á¤½Ã
else
{
m_pyBackground.SetViewDistanceSet(0, float(m_iForceSightRange));
}
float fFar=25600.0f;
float fNear=MIN_FOG;
double dbAvePow=double(1000.0f/s_fAveRenderTime);
double dbMaxPow=60.0;
float fDistance=std::max((float)(fNear+(fFar-fNear)*(dbAvePow)/dbMaxPow), fNear);
m_pyBackground.SetViewDistanceSet(0, fDistance);
}
// °Å¸® °­Á¦ ¼³Á¤½Ã
else
{
// 10000 Æú¸®°ï º¸´Ù ÀûÀ»¶§´Â °¡Àå ¸Ö¸® º¸ÀÌ°Ô ÇÑ´Ù
m_pyBackground.SetViewDistanceSet(0, 25600.0f);
m_pyBackground.SetViewDistanceSet(0, float(m_iForceSightRange));
}
}
else
{
// 10000 Æú¸®°ï º¸´Ù ÀûÀ»¶§´Â °¡Àå ¸Ö¸® º¸ÀÌ°Ô ÇÑ´Ù
m_pyBackground.SetViewDistanceSet(0, 25600.0f);
}
++s_dwRenderFrameCount;
if (bSampleRenderTelemetry)
{
++m_dwRenderTelemetryRenderCount;
m_fRenderTelemetryRenderTimeSumMS += static_cast<double>(m_dwCurRenderTime);
m_fRenderTelemetryInterpolationSum += static_cast<double>(m_fRenderInterpolationFactor);
if (m_dwRenderTelemetryLastPresentTime)
{
m_fRenderTelemetryPresentGapSumMS += static_cast<double>(dwRenderEndTime - m_dwRenderTelemetryLastPresentTime);
++m_dwRenderTelemetryPresentGapSamples;
}
++s_dwRenderFrameCount;
m_dwRenderTelemetryLastPresentTime = dwRenderEndTime;
}
}
}
int rest = s_uiNextFrameTime - ELTimer_GetMSec();
if (bSampleRenderTelemetry && !didRender)
++m_dwRenderTelemetryBlockedRenderCount;
if (rest > 0 && !bCurrentLateUpdate )
if (m_iFPS > 0)
{
s_uiLoad -= rest; // ½® ½Ã°£Àº ·Îµå¿¡¼­ »«´Ù..
Sleep(rest);
}
const double fRenderFrameMS = 1000.0 / static_cast<double>(m_iFPS);
s_fNextRenderTime += fRenderFrameMS;
++s_dwUpdateFrameCount;
const double fSleepMS = s_fNextRenderTime - static_cast<double>(ELTimer_GetMSec());
if (fSleepMS > 0.999)
{
const UINT uiSleepMS = static_cast<UINT>(fSleepMS);
s_uiLoad -= std::min(s_uiLoad, uiSleepMS);
const DWORD dwSleepStart = ELTimer_GetMSec();
Sleep(static_cast<DWORD>(fSleepMS));
if (bSampleRenderTelemetry)
m_fRenderTelemetrySleepTimeSumMS += static_cast<double>(ELTimer_GetMSec() - dwSleepStart);
}
}
else
{
s_fNextRenderTime = static_cast<double>(ELTimer_GetMSec());
}
s_uiLoad += ELTimer_GetMSec() - dwStart;
//m_Profiler.ProfileByScreen();
const DWORD dwTelemetryNow = ELTimer_GetMSec();
if (bSampleRenderTelemetry && dwTelemetryNow - m_dwRenderTelemetryWindowStartMS >= m_dwRenderTelemetryIntervalMS)
FlushRenderTelemetryWindow(dwTelemetryNow);
//m_Profiler.ProfileByScreen();
return true;
}
void CPythonApplication::ApplyRenderInterpolation()
{
const float fInterpolation = ClampInterpolationFactor(m_fRenderInterpolationFactor);
m_kChrMgr.ApplyRenderInterpolation(fInterpolation);
if (CAMERA_MODE_NORMAL != m_iCameraMode)
return;
if (0.0f != m_kCmrPos.m_fViewDir || 0.0f != m_kCmrPos.m_fCrossDir || 0.0f != m_kCmrPos.m_fUpDir)
return;
CCamera* pCurrentCamera = CCameraManager::Instance().GetCurrentCamera();
if (!pCurrentCamera)
return;
const D3DXVECTOR3 v3CenterPosition = LerpVector3(m_v3LastCenterPosition, m_v3CenterPosition, fInterpolation);
const float fDistance = pCurrentCamera->GetDistance();
const float fPitch = pCurrentCamera->GetPitch();
const float fRotation = pCurrentCamera->GetRoll();
m_pyGraphic.SetPositionCamera(
v3CenterPosition.x,
v3CenterPosition.y,
v3CenterPosition.z + pCurrentCamera->GetTargetHeight(),
fDistance,
fPitch,
fRotation);
}
void CPythonApplication::UpdateClientRect()
{
RECT rcApp;
@@ -1023,7 +1309,39 @@ float CPythonApplication::GetGlobalElapsedTime()
void CPythonApplication::SetFPS(int iFPS)
{
m_iFPS = iFPS;
m_iFPS = std::max(0, iFPS);
char szTargetFPS[16];
FormatRenderTargetFPSLabel(m_iFPS, szTargetFPS, sizeof(szTargetFPS));
m_stRenderTelemetrySummary = "Render telemetry\nTarget FPS: ";
m_stRenderTelemetrySummary += szTargetFPS;
m_stRenderTelemetrySummary += "\nCollecting frame pacing...";
if (IsRenderTelemetrySamplingEnabled())
ResetRenderTelemetryWindow(ELTimer_GetMSec());
if (m_bRenderTelemetryEnabled)
{
AppendRenderTelemetryTrace(
"set_fps elapsed_ms=%lu target_fps=%u",
static_cast<unsigned long>(ELTimer_GetMSec() - m_dwStartLocalTime),
m_iFPS);
}
}
void CPythonApplication::SetPerformanceHUDVisible(bool isVisible)
{
m_bRenderTelemetryHudVisible = isVisible;
if (IsRenderTelemetrySamplingEnabled())
ResetRenderTelemetryWindow(ELTimer_GetMSec());
else
m_dwRenderTelemetryWindowStartMS = 0;
if (m_bRenderTelemetryEnabled)
{
AppendRenderTelemetryTrace(
"set_hud elapsed_ms=%lu visible=%u",
static_cast<unsigned long>(ELTimer_GetMSec() - m_dwStartLocalTime),
m_bRenderTelemetryHudVisible ? 1u : 0u);
}
}
int CPythonApplication::GetWidth()
@@ -1075,6 +1393,7 @@ void CPythonApplication::Destroy()
// SphereMap
CGrannyMaterial::DestroySphereMap();
m_kRenderTelemetryTextLine.Destroy();
m_kWndMgr.Destroy();
CPythonSystem::Instance().SaveConfig();

View File

@@ -4,6 +4,7 @@
#include "eterLib/Input.h"
#include "eterLib/Profiler.h"
#include "eterLib/GrpDevice.h"
#include "EterLib/GrpTextInstance.h"
#include "eterLib/NetDevice.h"
#include "eterLib/GrpLightManager.h"
#include "eterLib/GameThreadPool.h"
@@ -42,7 +43,13 @@
#include "AbstractApplication.h"
#include "MovieMan.h"
#include <qedit.h>
struct IGraphBuilder;
struct IBaseFilter;
struct ISampleGrabber;
struct IMediaControl;
struct IMediaEventEx;
struct IVideoWindow;
struct IBasicVideo;
class CPythonApplication : public CMSApplication, public CInputKeyboard, public IAbstractApplication
{
@@ -206,6 +213,7 @@ class CPythonApplication : public CMSApplication, public CInputKeyboard, public
float GetPitch();
void SetFPS(int iFPS);
void SetPerformanceHUDVisible(bool isVisible);
void SetServerTime(time_t tTime);
time_t GetServerTime();
time_t GetServerTimeStamp();
@@ -303,6 +311,12 @@ class CPythonApplication : public CMSApplication, public CInputKeyboard, public
BOOL __IsContinuousChangeTypeCursor(int iCursorNum);
void __UpdateCamera();
void ApplyRenderInterpolation();
bool IsRenderTelemetrySamplingEnabled() const;
void InitializeRenderRuntimeOverrides();
void ResetRenderTelemetryWindow(DWORD dwNow);
void FlushRenderTelemetryWindow(DWORD dwNow);
void RenderPerformanceHUD();
void __SetFullScreenWindow(HWND hWnd, DWORD dwWidth, DWORD dwHeight, DWORD dwBPP);
void __MinimizeFullScreenWindow(HWND hWnd, DWORD dwWidth, DWORD dwHeight);
@@ -357,9 +371,28 @@ class CPythonApplication : public CMSApplication, public CInputKeyboard, public
PyObject * m_poMouseHandler;
D3DXVECTOR3 m_v3CenterPosition;
D3DXVECTOR3 m_v3LastCenterPosition;
CGraphicTextInstance m_kRenderTelemetryTextLine;
std::string m_stRenderTelemetrySummary;
unsigned int m_iFPS;
float m_fRenderInterpolationFactor;
float m_fAveRenderTime;
bool m_bRenderTelemetryEnabled;
bool m_bRenderTelemetryHudVisible;
DWORD m_dwRenderTelemetryIntervalMS;
DWORD m_dwRenderTelemetryWindowStartMS;
DWORD m_dwRenderTelemetryLoopCount;
DWORD m_dwRenderTelemetryUpdateCount;
DWORD m_dwRenderTelemetryRenderCount;
DWORD m_dwRenderTelemetryBlockedRenderCount;
DWORD m_dwRenderTelemetryLastPresentTime;
DWORD m_dwRenderTelemetryPresentGapSamples;
double m_fRenderTelemetryUpdateTimeSumMS;
double m_fRenderTelemetryRenderTimeSumMS;
double m_fRenderTelemetrySleepTimeSumMS;
double m_fRenderTelemetryInterpolationSum;
double m_fRenderTelemetryPresentGapSumMS;
DWORD m_dwCurRenderTime;
DWORD m_dwCurUpdateTime;
DWORD m_dwLoad;

View File

@@ -358,6 +358,34 @@ struct FCharacterManagerCharacterInstanceDeform
//pInstance->Update();
}
};
struct FCharacterManagerCharacterInstanceApplyRenderInterpolation
{
explicit FCharacterManagerCharacterInstanceApplyRenderInterpolation(float fInterpolation)
: m_fInterpolation(fInterpolation)
{
}
inline void operator () (const std::pair<DWORD, CInstanceBase*>& cr_Pair)
{
cr_Pair.second->ApplyRenderInterpolation(m_fInterpolation);
}
float m_fInterpolation;
};
struct FCharacterManagerCharacterInstanceListApplyRenderInterpolation
{
explicit FCharacterManagerCharacterInstanceListApplyRenderInterpolation(float fInterpolation)
: m_fInterpolation(fInterpolation)
{
}
inline void operator () (CInstanceBase* pInstance)
{
pInstance->ApplyRenderInterpolation(m_fInterpolation);
}
float m_fInterpolation;
};
struct FCharacterManagerCharacterInstanceListDeform
{
inline void operator () (CInstanceBase * pInstance)
@@ -366,6 +394,12 @@ struct FCharacterManagerCharacterInstanceListDeform
}
};
void CPythonCharacterManager::ApplyRenderInterpolation(float fInterpolation)
{
std::for_each(m_kAliveInstMap.begin(), m_kAliveInstMap.end(), FCharacterManagerCharacterInstanceApplyRenderInterpolation(fInterpolation));
std::for_each(m_kDeadInstList.begin(), m_kDeadInstList.end(), FCharacterManagerCharacterInstanceListApplyRenderInterpolation(fInterpolation));
}
void CPythonCharacterManager::Deform()
{
std::for_each(m_kAliveInstMap.begin(), m_kAliveInstMap.end(), FCharacterManagerCharacterInstanceDeform());

View File

@@ -57,6 +57,7 @@ class CPythonCharacterManager : public CSingleton<CPythonCharacterManager>, publ
void DestroyDeviceObjects();
void Update();
void ApplyRenderInterpolation(float fInterpolation);
void Deform();
void Render();
void RenderShadowMainInstance();

View File

@@ -856,15 +856,14 @@ PyObject * chrGetProjectPosition(PyObject* poSelf, PyObject* poArgs)
if (!pInstance)
return Py_BuildValue("ii", -100, -100);
TPixelPosition PixelPosition;
pInstance->NEW_GetPixelPosition(&PixelPosition);
const D3DXVECTOR3& c_rv3Position = pInstance->GetGraphicThingInstanceRef().GetPosition();
CPythonGraphic & rpyGraphic = CPythonGraphic::Instance();
float fx, fy, fz;
rpyGraphic.ProjectPosition(PixelPosition.x,
-PixelPosition.y,
PixelPosition.z + float(iHeight),
rpyGraphic.ProjectPosition(c_rv3Position.x,
c_rv3Position.y,
c_rv3Position.z + float(iHeight),
&fx, &fy, &fz);
if (1 == int(fz))

View File

@@ -317,6 +317,7 @@ class CPythonNetworkStream : public CNetworkStream, public CSingleton<CPythonNet
// Main Game Phase
bool SendC2CPacket(DWORD dwSize, void * pData);
bool SendChatPacket(const char * c_szChat, BYTE byType = CHAT_TYPE_TALKING);
bool SendBiologSubmit();
bool SendWhisperPacket(const char * name, const char * c_szChat);
bool SendMessengerAddByVIDPacket(DWORD vid);
bool SendMessengerAddByNamePacket(const char * c_szName);

View File

@@ -499,6 +499,13 @@ PyObject* netSendChatPacket(PyObject* poSelf, PyObject* poArgs)
return Py_BuildNone();
}
PyObject* netSendBiologSubmit(PyObject* poSelf, PyObject* poArgs)
{
CPythonNetworkStream& rkNetStream = CPythonNetworkStream::Instance();
rkNetStream.SendBiologSubmit();
return Py_BuildNone();
}
PyObject* netSendEmoticon(PyObject* poSelf, PyObject* poArgs)
{
int eEmoticon;
@@ -1700,6 +1707,7 @@ void initnet()
{ "IsConnect", netIsConnect, METH_VARARGS },
{ "SendChatPacket", netSendChatPacket, METH_VARARGS },
{ "SendBiologSubmit", netSendBiologSubmit, METH_VARARGS },
{ "SendEmoticon", netSendEmoticon, METH_VARARGS },
{ "SendWhisperPacket", netSendWhisperPacket, METH_VARARGS },

View File

@@ -20,6 +20,7 @@
#include "PythonApplication.h"
#include "GameLib/ItemManager.h"
#include "PackLib/PackProfile.h"
#include "AbstractApplication.h"
#include "AbstractCharacterManager.h"
@@ -414,6 +415,7 @@ void CPythonNetworkStream::SetGamePhase()
if ("Game"!=m_strPhase)
m_phaseLeaveFunc.Run();
MarkPackProfilePhase("game");
m_strPhase = "Game";
m_dwChangingPhaseTime = ELTimer_GetMSec();
@@ -786,6 +788,11 @@ bool CPythonNetworkStream::SendChatPacket(const char * c_szChat, BYTE byType)
return true;
}
bool CPythonNetworkStream::SendBiologSubmit()
{
return SendChatPacket("/biolog_submit");
}
//////////////////////////////////////////////////////////////////////////
// Emoticon
void CPythonNetworkStream::RegisterEmoticonString(const char * pcEmoticonString)
@@ -4355,4 +4362,4 @@ void CPythonNetworkStream::Discord_Close()
{
Discord_Shutdown();
}
#endif
#endif

View File

@@ -5,6 +5,7 @@
#include "NetworkActorManager.h"
#include "AbstractPlayer.h"
#include "PackLib/PackManager.h"
#include "PackLib/PackProfile.h"
void CPythonNetworkStream::EnableChatInsultFilter(bool isEnable)
{
@@ -90,6 +91,7 @@ void CPythonNetworkStream::SetLoadingPhase()
Tracen("");
Tracen("## Network - Loading Phase ##");
Tracen("");
MarkPackProfilePhase("loading");
m_strPhase = "Loading";

View File

@@ -2,6 +2,7 @@
#include "PythonNetworkStream.h"
#include "Packet.h"
#include "AccountConnector.h"
#include "PackLib/PackProfile.h"
// Login ---------------------------------------------------------------------------
void CPythonNetworkStream::LoginPhase()
@@ -18,6 +19,7 @@ void CPythonNetworkStream::SetLoginPhase()
Tracen("");
Tracen("## Network - Login Phase ##");
Tracen("");
MarkPackProfilePhase("login");
m_strPhase = "Login";

View File

@@ -318,6 +318,8 @@ void CPythonSystem::SetDefaultConfig()
m_Config.bAlwaysShowName = DEFAULT_VALUE_ALWAYS_SHOW_NAME;
m_Config.bShowDamage = true;
m_Config.bShowSalesText = true;
m_Config.iRenderFPS = 60;
m_Config.bShowPerformanceHUD = false;
}
bool CPythonSystem::IsWindowed()
@@ -365,6 +367,26 @@ void CPythonSystem::SetShowSalesTextFlag(int iFlag)
m_Config.bShowSalesText = iFlag == 1 ? true : false;
}
int CPythonSystem::GetRenderFPS()
{
return m_Config.iRenderFPS;
}
void CPythonSystem::SetRenderFPS(int iFPS)
{
m_Config.iRenderFPS = std::max(0, std::min(iFPS, 500));
}
bool CPythonSystem::IsShowPerformanceHUD()
{
return m_Config.bShowPerformanceHUD;
}
void CPythonSystem::SetShowPerformanceHUDFlag(int iFlag)
{
m_Config.bShowPerformanceHUD = iFlag == 1 ? true : false;
}
bool CPythonSystem::IsAutoTiling()
{
if (m_Config.bSoftwareTiling == 0)
@@ -462,6 +484,10 @@ bool CPythonSystem::LoadConfig()
m_Config.bShowDamage = atoi(value) == 1 ? true : false;
else if (!stricmp(command, "SHOW_SALESTEXT"))
m_Config.bShowSalesText = atoi(value) == 1 ? true : false;
else if (!stricmp(command, "RENDER_FPS"))
m_Config.iRenderFPS = std::max(0, std::min(atoi(value), 500));
else if (!stricmp(command, "SHOW_PERFORMANCE_HUD"))
m_Config.bShowPerformanceHUD = atoi(value) == 1 ? true : false;
}
if (m_Config.bWindowed)
@@ -552,6 +578,8 @@ bool CPythonSystem::SaveConfig()
fprintf(fp, "USE_DEFAULT_IME %d\n", m_Config.bUseDefaultIME);
fprintf(fp, "SOFTWARE_TILING %d\n", m_Config.bSoftwareTiling);
fprintf(fp, "SHADOW_LEVEL %d\n", m_Config.iShadowLevel);
fprintf(fp, "RENDER_FPS %d\n", m_Config.iRenderFPS);
fprintf(fp, "SHOW_PERFORMANCE_HUD %d\n", m_Config.bShowPerformanceHUD);
// MR-14: Fog update by Alaric
fprintf(fp, "FOG_LEVEL %d\n", m_Config.iFogLevel);
// MR-14: -- END OF -- Fog update by Alaric
@@ -607,6 +635,9 @@ const CPythonSystem::TWindowStatus & CPythonSystem::GetWindowStatusReference(int
void CPythonSystem::ApplyConfig() // 이전 설정과 현재 설정을 비교해서 바뀐 설정을 적용 한다.
{
const bool bRenderFPSChanged = m_OldConfig.iRenderFPS != m_Config.iRenderFPS;
const bool bPerformanceHUDChanged = m_OldConfig.bShowPerformanceHUD != m_Config.bShowPerformanceHUD;
if (m_OldConfig.gamma != m_Config.gamma)
{
float val = 1.0f;
@@ -631,6 +662,12 @@ void CPythonSystem::ApplyConfig() // 이전 설정과 현재 설정을 비교해
CPythonApplication::Instance().SetCursorMode(CPythonApplication::CURSOR_MODE_HARDWARE);
}
if (bRenderFPSChanged)
CPythonApplication::Instance().SetFPS(m_Config.iRenderFPS);
if (bPerformanceHUDChanged)
CPythonApplication::Instance().SetPerformanceHUDVisible(m_Config.bShowPerformanceHUD);
m_OldConfig = m_Config;
ChangeSystem();

View File

@@ -78,6 +78,8 @@ class CPythonSystem : public CSingleton<CPythonSystem>
bool bAlwaysShowName;
bool bShowDamage;
bool bShowSalesText;
int iRenderFPS;
bool bShowPerformanceHUD;
} TConfig;
public:
@@ -157,6 +159,11 @@ class CPythonSystem : public CSingleton<CPythonSystem>
void SetFogLevel(unsigned int level);
// MR-14: -- END OF -- Fog update by Alaric
int GetRenderFPS();
void SetRenderFPS(int iFPS);
bool IsShowPerformanceHUD();
void SetShowPerformanceHUDFlag(int iFlag);
protected:
TResolution m_ResolutionList[RESOLUTION_MAX_NUM];
int m_ResolutionCount;
@@ -167,4 +174,4 @@ class CPythonSystem : public CSingleton<CPythonSystem>
bool m_isInterfaceConfig;
PyObject * m_poInterfaceHandler;
TWindowStatus m_WindowStatus[WINDOW_MAX_NUM];
};
};

View File

@@ -226,6 +226,36 @@ PyObject * systemIsShowSalesText(PyObject * poSelf, PyObject * poArgs)
return Py_BuildValue("i", CPythonSystem::Instance().IsShowSalesText());
}
PyObject * systemGetRenderFPS(PyObject * poSelf, PyObject * poArgs)
{
return Py_BuildValue("i", CPythonSystem::Instance().GetRenderFPS());
}
PyObject * systemSetRenderFPS(PyObject * poSelf, PyObject * poArgs)
{
int iFPS;
if (!PyTuple_GetInteger(poArgs, 0, &iFPS))
return Py_BuildException();
CPythonSystem::Instance().SetRenderFPS(iFPS);
return Py_BuildNone();
}
PyObject * systemIsShowPerformanceHUD(PyObject * poSelf, PyObject * poArgs)
{
return Py_BuildValue("i", CPythonSystem::Instance().IsShowPerformanceHUD());
}
PyObject * systemSetShowPerformanceHUDFlag(PyObject * poSelf, PyObject * poArgs)
{
int iFlag;
if (!PyTuple_GetInteger(poArgs, 0, &iFlag))
return Py_BuildException();
CPythonSystem::Instance().SetShowPerformanceHUDFlag(iFlag);
return Py_BuildNone();
}
PyObject * systemSetConfig(PyObject * poSelf, PyObject * poArgs)
{
int res_index;
@@ -445,6 +475,11 @@ void initsystem()
{ "SetShowSalesTextFlag", systemSetShowSalesTextFlag, METH_VARARGS },
{ "IsShowSalesText", systemIsShowSalesText, METH_VARARGS },
{ "GetRenderFPS", systemGetRenderFPS, METH_VARARGS },
{ "SetRenderFPS", systemSetRenderFPS, METH_VARARGS },
{ "IsShowPerformanceHUD", systemIsShowPerformanceHUD, METH_VARARGS },
{ "SetShowPerformanceHUDFlag", systemSetShowPerformanceHUDFlag, METH_VARARGS },
{ "GetShadowLevel", systemGetShadowLevel, METH_VARARGS },
{ "SetShadowLevel", systemSetShadowLevel, METH_VARARGS },