feat: GuiPresence (chat-service presence backend, gateway route, mod module); restore TODO history

This commit is contained in:
loki5512344 2026-10-02 13:41:49 +02:00
parent 8d9a2ad0d6
commit e82efb74e7
Signed by: boba
GPG key ID: 253067914055423B
44 changed files with 2202 additions and 19 deletions

View file

@ -19,6 +19,7 @@ GATEWAY_PORT=8080
ACCOUNTS_HTTP_URL=http://127.0.0.1:8081
ACCOUNTS_GRPC_URL=http://127.0.0.1:50051
CONFIGS_HTTP_URL=http://127.0.0.1:8082
CHAT_HTTP_URL=http://127.0.0.1:8083
SITE_ORIGIN=http://localhost:5173
TRUST_PROXY=false
# Directory the gateway serves under GET /downloads/* (lovisual.jar lives here;

17
backend/Cargo.lock generated
View file

@ -860,6 +860,23 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "chat-service"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"axum-test",
"common",
"dotenvy",
"serde",
"serde_json",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "chrono"
version = "0.4.45"

View file

@ -2,6 +2,7 @@
resolver = "2"
members = [
"accounts-service",
"chat-service",
"common",
"configs-service",
"gateway",
@ -9,5 +10,4 @@ members = [
# Planned members, added when their own implementation plan starts
# (see backend/STRUCTURE.md and TODO.md Фаза 10 / Подсистемы 1-3):
# "chat-service" — RPC chat, friends, presence, telemetry widgets (Подсистема 2)
# "addons-registry" — addon marketplace backend (Подсистема 3)

View file

@ -17,9 +17,9 @@ backend/
configs-service/ # РЕАЛИЗОВАН — configs-service/PLAN.md: 4 слота конфигов,
# share-коды, витрина publish/browse/detail/copy
# (Подсистема 1, часть 2)
chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence,
# виджеты-телеметрия (Подсистема 2). Единственный
# сервис с WebSocket, а не только REST.
chat-service/ # РЕАЛИЗОВАН ЧАСТИЧНО — chat-service/PLAN.md: сейчас только GUI presence
# (в памяти, POST /presence/gui); RPC-чат, друзья, виджеты-телеметрия
# (Подсистема 2) позже, там будет WebSocket.
addons-registry/ # ПЛАН НЕ НАПИСАН — витрина аддонов, версии,
# публикация/модерация (Подсистема 3)
```

View file

@ -9,6 +9,7 @@ COPY common ./common
COPY accounts-service ./accounts-service
COPY gateway ./gateway
COPY configs-service ./configs-service
COPY chat-service ./chat-service
RUN cargo build --release -p accounts-service
FROM debian:trixie-slim

View file

@ -0,0 +1,23 @@
[package]
name = "chat-service"
version = "0.1.0"
edition = "2024"
[lib]
name = "chat_service"
path = "src/lib.rs"
[dependencies]
common = { path = "../common" }
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
tracing = "0.1"
tracing-subscriber = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
anyhow = "1"
dotenvy = "0.15"
[dev-dependencies]
axum-test = "21"

View file

@ -0,0 +1,20 @@
# Build context must be backend/ (the workspace root).
FROM rust:1-trixie AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
protobuf-compiler pkg-config libssl-dev ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY Cargo.toml Cargo.lock ./
COPY common ./common
COPY accounts-service ./accounts-service
COPY gateway ./gateway
COPY configs-service ./configs-service
COPY chat-service ./chat-service
RUN cargo build --release -p chat-service
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/chat-service /usr/local/bin/chat-service
ENTRYPOINT ["/usr/local/bin/chat-service"]

View file

@ -0,0 +1,47 @@
# chat-service
Подсистема 2 платформы (RPC-чат, друзья, presence, телеметрия виджетов). Сейчас реализован только **GUI presence**;
чат и друзья позже (WebSocket). Сервис без БД: состояние в памяти, живёт секунды.
## GUI presence (идея из evo `GuiPresence`, наш вариант)
Зачем: LoVisual-игроки видят друг у друга над рукой мини-копию открытого меню клиента (какая категория, куда наведён курсор,
поиск), и знают, кто вообще пользуется LoVisual (значок у ника). Без видео и без чужих данных: только положение курсора и флаги.
### Протокол
`POST /presence/gui` (через gateway, нужен device-токен `lvd_...`, gateway кладёт `x-lovisual-account-id`):
```json
{ "server": "play.example.com", "mc": "<uuid игрока>", "gui": "<base64>|null", "want": ["<uuid>", "..."], "publish": true }
```
Ответ: `{ "players": [ { "mc": "<uuid>", "gui": "<base64>|null", "age": 120 } ] }`, `age` в мс с последнего обновления.
- Один запрос и публикует состояние вызывающего, и возвращает состояние тех, кого он видит (`want`, не больше 40).
- `publish = false` (по умолчанию true): только чтение, вызывающий сам не появляется у других.
- `gui = null` значит «меню закрыто, но LoVisual запущен» (значок у ника). Запись живёт 6 с без обновления.
- Игроки разных серверов не видят друг друга (ключ `server`, нормализуется в нижний регистр, ≤64 символов).
- Частота клиента: раз в ~350 мс при открытом меню (пачка до 8 сэмплов курсора по 100 мс), раз в 2 с при закрытом.
Лимит на gateway: 6 запросов в секунду на аккаунт.
### Формат `gui` (36 байт максимум, base64 ≤48 символов; сервис его не разбирает, только проверяет алфавит и длину)
| байт | значение |
|---|---|
| 0 | версия = 1 |
| 1 | биты 0-3 категория (0..15), бит 4 открыта страница настроек, бит 5 поиск, бит 7 меню закрыто |
| 2 | индекс модуля под курсором в списке категории (255 = нет) |
| 3 | число сэмплов n (0..8) |
| 4+4i | сэмпл: x (0..255 по ширине меню), y (0..255 по высоте), бит 0 = зажата ЛКМ, прокрутка списка (0..255) |
### Безопасность
- Идентичность только из заголовка gateway; прямой доступ закрыт общим секретом (как у остальных сервисов).
- Привязка `mc uuid` к аккаунту: чужой аккаунт не может публиковать за занятый uuid (409), привязка снимается через 10 мин
простоя; аккаунт владеет одним uuid, смена альта освобождает прежний. Это защита от подделки, не доказательство владения
аккаунтом Minecraft: серверной верификации uuid нет.
- Размер тела ≤8 КБ, payload только `[A-Za-z0-9+/=]`, `want` ≤40.
### Структура
`src/presence/{payload.rs (валидация), store.rs (состояние + TTL + привязка), handlers.rs (sync)}`; тесты: модульные в `store.rs`,
`payload.rs`, интеграционные `tests/presence.rs`. Порт `CHAT_PORT` (8083), `INTERNAL_KEY` общий. В gateway маршрут `/presence/*`.
### Не сделано
Просмотр видео (`.watch` у evo), чат и друзья (WebSocket), метрики, горизонтальное масштабирование (состояние в памяти одного процесса).

View file

@ -1,5 +0,0 @@
# chat-service (planned)
RPC chat, friends, presence, telemetry widgets (Подсистема 2). WebSocket, not
just REST — see `TODO.md` (Фаза 10, "Подсистема 2") and
`backend/STRUCTURE.md`. No implementation plan yet.

View file

@ -0,0 +1,45 @@
use anyhow::{Context, Result};
#[derive(Clone)]
pub struct Config {
pub port: u16,
pub internal_key: String,
}
impl Config {
pub fn from_env() -> Result<Config> {
Ok(Config {
port: std::env::var("CHAT_PORT")
.unwrap_or_else(|_| "8083".into())
.parse()
.context("CHAT_PORT")?,
internal_key: std::env::var("INTERNAL_KEY").context("INTERNAL_KEY not set")?,
})
}
pub fn validate(&self) -> Result<()> {
if self.internal_key.len() < 32 {
anyhow::bail!("INTERNAL_KEY must be at least 32 bytes");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_key_is_rejected() {
let c = Config {
port: 0,
internal_key: "short".into(),
};
assert!(c.validate().is_err());
let c = Config {
port: 0,
internal_key: "k".repeat(32),
};
assert!(c.validate().is_ok());
}
}

View file

@ -0,0 +1,27 @@
pub mod config;
pub mod presence;
use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}};
use common::internal::{InternalKey, require_internal_key};
use config::Config;
use presence::store::Store;
use std::sync::Arc;
/// Presence requests are tiny JSON (a ≤64-char payload and ≤40 uuids).
const MAX_BODY_BYTES: usize = 8 * 1024;
pub fn build_app(cfg: &Config) -> (Router, Arc<Store>) {
let store = Arc::new(Store::default());
let api = Router::new()
.route("/presence/gui", post(presence::handlers::sync))
.with_state(Arc::clone(&store))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.layer(axum::middleware::from_fn_with_state(
InternalKey::new(cfg.internal_key.clone()),
require_internal_key,
));
let app = Router::new()
.route("/health", get(|| async { "ok" }))
.merge(api);
(app, store)
}

View file

@ -0,0 +1,13 @@
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::fmt::init();
let cfg = chat_service::config::Config::from_env()?;
cfg.validate()?;
let (app, state) = chat_service::build_app(&cfg);
chat_service::presence::spawn_purger(state);
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
tracing::info!("chat-service listening on {}", cfg.port);
axum::serve(listener, app).await?;
Ok(())
}

View file

@ -0,0 +1,77 @@
use super::{
payload::{MAX_WANT, normalize_server, valid_payload},
store::{PublishError, Store},
};
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use common::internal::GatewayIdentity;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{sync::Arc, time::Instant};
use uuid::Uuid;
#[derive(Deserialize)]
pub struct SyncRequest {
pub server: String,
pub mc: Uuid,
/// Base64 GUI payload, or null when this client has no menu open (still
/// marks the player as a LoVisual user for a few seconds).
#[serde(default)]
pub gui: Option<String>,
/// Minecraft uuids of nearby players the caller wants state for.
#[serde(default)]
pub want: Vec<Uuid>,
/// False for a read-only poll: the caller sees others but does not appear itself.
#[serde(default = "yes")]
pub publish: bool,
}
fn yes() -> bool {
true
}
#[derive(Serialize)]
pub struct PlayerState {
pub mc: Uuid,
pub gui: Option<String>,
pub age: u64,
}
fn error(status: StatusCode, message: &str) -> Response {
(status, Json(json!({ "error": message }))).into_response()
}
/// One round trip both publishes the caller's state and returns the state of
/// the players the caller can see (same request as the mod's poll loop).
pub async fn sync(
State(store): State<Arc<Store>>,
id: GatewayIdentity,
Json(req): Json<SyncRequest>,
) -> Response {
let Some(server) = normalize_server(&req.server) else {
return error(StatusCode::BAD_REQUEST, "invalid server");
};
if req.want.len() > MAX_WANT {
return error(StatusCode::BAD_REQUEST, "too many players requested");
}
if req.gui.as_deref().is_some_and(|g| !valid_payload(g)) {
return error(StatusCode::BAD_REQUEST, "invalid gui payload");
}
let now = Instant::now();
if req.publish
&& let Err(PublishError::Claimed) = store.publish(id.account_id, &server, req.mc, req.gui, now)
{
return error(StatusCode::CONFLICT, "player uuid is bound to another account");
}
let players: Vec<PlayerState> = store
.lookup(&server, &req.want, now)
.into_iter()
.filter(|s| s.mc != req.mc)
.map(|s| PlayerState { mc: s.mc, gui: s.gui, age: s.age_ms })
.collect();
Json(json!({ "players": players })).into_response()
}

View file

@ -0,0 +1,20 @@
//! GUI presence: lets LoVisual users see which client menu nearby LoVisual
//! users have open. State is in-memory and short-lived (a few seconds), never
//! persisted; see `backend/chat-service/PLAN.md`.
pub mod handlers;
pub mod payload;
pub mod store;
use std::{sync::Arc, time::{Duration, Instant}};
use store::Store;
pub fn spawn_purger(store: Arc<Store>) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(30));
loop {
tick.tick().await;
store.purge(Instant::now());
}
});
}

View file

@ -0,0 +1,46 @@
//! Validation of the opaque GUI payload and server key. The payload is a
//! base64 string produced by the mod (header + up to 8 cursor samples); the
//! service never decodes it, it only bounds what it stores and relays.
/// Header (4 bytes) + 8 samples x 4 bytes = 36 bytes = 48 base64 characters.
pub const MAX_PAYLOAD_CHARS: usize = 48;
pub const MAX_SERVER_CHARS: usize = 64;
pub const MAX_WANT: usize = 40;
pub fn valid_payload(s: &str) -> bool {
!s.is_empty()
&& s.len() <= MAX_PAYLOAD_CHARS
&& s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
}
/// Normalised server key: trimmed, lower-case, no control characters, bounded.
pub fn normalize_server(s: &str) -> Option<String> {
let s = s.trim().to_ascii_lowercase();
if s.is_empty() || s.chars().count() > MAX_SERVER_CHARS || s.chars().any(|c| c.is_control()) {
return None;
}
Some(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn payload_charset_and_length() {
assert!(valid_payload("AQEAAAA="));
assert!(!valid_payload(""));
assert!(!valid_payload("has space"));
assert!(!valid_payload("<script>"));
assert!(!valid_payload(&"A".repeat(MAX_PAYLOAD_CHARS + 1)));
assert!(valid_payload(&"A".repeat(MAX_PAYLOAD_CHARS)));
}
#[test]
fn server_is_normalised() {
assert_eq!(normalize_server(" Play.Example.COM ").as_deref(), Some("play.example.com"));
assert_eq!(normalize_server(""), None);
assert_eq!(normalize_server("a\nb"), None);
assert_eq!(normalize_server(&"x".repeat(65)), None);
}
}

View file

@ -0,0 +1,188 @@
use super::payload::MAX_WANT;
use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
use uuid::Uuid;
/// A published payload stops being relayed after this long without a refresh.
pub const ENTRY_TTL: Duration = Duration::from_secs(6);
/// A Minecraft uuid stays claimed by an account this long after its last publish.
pub const CLAIM_TTL: Duration = Duration::from_secs(600);
#[derive(Debug, PartialEq, Eq)]
pub enum PublishError {
/// The uuid is currently claimed by a different account.
Claimed,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Seen {
pub mc: Uuid,
pub gui: Option<String>,
pub age_ms: u64,
}
struct Entry {
gui: Option<String>,
updated: Instant,
}
struct Claim {
account: Uuid,
seen: Instant,
}
#[derive(Default)]
struct Inner {
entries: HashMap<(String, Uuid), Entry>,
claims: HashMap<Uuid, Claim>,
by_account: HashMap<Uuid, Uuid>,
}
/// In-memory presence state. A uuid is bound to one account at a time so a
/// client cannot impersonate somebody else's menu; an account owns one uuid at
/// a time (switching alts releases the previous claim).
#[derive(Default)]
pub struct Store {
inner: Mutex<Inner>,
}
impl Store {
pub fn publish(
&self,
account: Uuid,
server: &str,
mc: Uuid,
gui: Option<String>,
now: Instant,
) -> Result<(), PublishError> {
let mut inner = self.inner.lock().expect("presence lock");
if let Some(claim) = inner.claims.get(&mc)
&& claim.account != account
&& now.duration_since(claim.seen) < CLAIM_TTL
{
return Err(PublishError::Claimed);
}
if let Some(previous) = inner.by_account.insert(account, mc)
&& previous != mc
{
inner.claims.remove(&previous);
inner.entries.retain(|(_, id), _| *id != previous);
}
inner.claims.insert(mc, Claim { account, seen: now });
inner
.entries
.insert((server.to_owned(), mc), Entry { gui, updated: now });
Ok(())
}
/// Fresh entries for the requested uuids on this server (bounded).
pub fn lookup(&self, server: &str, want: &[Uuid], now: Instant) -> Vec<Seen> {
let inner = self.inner.lock().expect("presence lock");
want.iter()
.take(MAX_WANT)
.filter_map(|mc| {
let entry = inner.entries.get(&(server.to_owned(), *mc))?;
let age = now.duration_since(entry.updated);
(age < ENTRY_TTL).then(|| Seen {
mc: *mc,
gui: entry.gui.clone(),
age_ms: age.as_millis() as u64,
})
})
.collect()
}
pub fn purge(&self, now: Instant) {
let mut inner = self.inner.lock().expect("presence lock");
inner.entries.retain(|_, e| now.duration_since(e.updated) < ENTRY_TTL);
let expired: Vec<Uuid> = inner
.claims
.iter()
.filter(|(_, c)| now.duration_since(c.seen) >= CLAIM_TTL)
.map(|(mc, _)| *mc)
.collect();
for mc in expired {
if let Some(claim) = inner.claims.remove(&mc) {
inner.by_account.remove(&claim.account);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> (Uuid, Uuid, Uuid, Uuid) {
(Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(10), Uuid::from_u128(20))
}
#[test]
fn published_entry_is_visible_then_expires() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "srv", mc, Some("AAAA".into()), t0).unwrap();
let seen = store.lookup("srv", &[mc], t0 + Duration::from_millis(500));
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].gui.as_deref(), Some("AAAA"));
assert_eq!(seen[0].age_ms, 500);
assert!(store.lookup("srv", &[mc], t0 + ENTRY_TTL).is_empty());
}
#[test]
fn entries_are_scoped_to_the_server() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "a", mc, None, t0).unwrap();
assert!(store.lookup("b", &[mc], t0).is_empty());
assert_eq!(store.lookup("a", &[mc], t0).len(), 1);
}
#[test]
fn other_account_cannot_take_a_claimed_uuid() {
let (acc, other, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc, None, t0).unwrap();
assert_eq!(store.publish(other, "s", mc, None, t0 + Duration::from_secs(1)), Err(PublishError::Claimed));
// after the claim goes idle it can be taken over
assert!(store.publish(other, "s", mc, None, t0 + CLAIM_TTL).is_ok());
}
#[test]
fn switching_uuid_releases_the_previous_claim() {
let (acc, other, mc1, mc2) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc1, None, t0).unwrap();
store.publish(acc, "s", mc2, None, t0).unwrap();
assert!(store.lookup("s", &[mc1], t0).is_empty());
assert!(store.publish(other, "s", mc1, None, t0).is_ok());
}
#[test]
fn lookup_is_bounded() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc, None, t0).unwrap();
let mut want: Vec<Uuid> = (100..100 + MAX_WANT as u128).map(Uuid::from_u128).collect();
want.push(mc);
assert!(store.lookup("s", &want, t0).is_empty());
}
#[test]
fn purge_drops_stale_state() {
let (acc, _, mc, _) = ids();
let store = Store::default();
let t0 = Instant::now();
store.publish(acc, "s", mc, None, t0).unwrap();
store.purge(t0 + CLAIM_TTL);
assert!(store.publish(Uuid::from_u128(2), "s", mc, None, t0 + CLAIM_TTL).is_ok());
}
}

View file

@ -0,0 +1,71 @@
use axum_test::TestServer;
use chat_service::config::Config;
use common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
use serde_json::{Value, json};
use uuid::Uuid;
const KEY: &str = "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk";
fn server() -> TestServer {
let cfg = Config { port: 0, internal_key: KEY.into() };
let (app, _) = chat_service::build_app(&cfg);
TestServer::new(app)
}
async fn sync(s: &TestServer, account: u128, body: Value) -> axum_test::TestResponse {
s.post("/presence/gui")
.add_header(INTERNAL_KEY_HEADER, KEY)
.add_header(ACCOUNT_ID_HEADER, Uuid::from_u128(account).to_string())
.json(&body)
.await
}
#[tokio::test]
async fn requires_internal_key_and_identity() {
let s = server();
let r = s.post("/presence/gui").json(&json!({})).await;
assert_eq!(r.status_code(), 403);
let r = s
.post("/presence/gui")
.add_header(INTERNAL_KEY_HEADER, KEY)
.json(&json!({"server": "a", "mc": Uuid::nil()}))
.await;
assert_eq!(r.status_code(), 401);
}
#[tokio::test]
async fn two_players_see_each_other() {
let s = server();
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
let r = sync(&s, 1, json!({"server": "Play.X", "mc": a, "gui": "AQEAAAA=", "want": [b]})).await;
assert_eq!(r.status_code(), 200);
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
let r = sync(&s, 2, json!({"server": "play.x", "mc": b, "gui": null, "want": [a]})).await;
let body = r.json::<Value>();
let players = body["players"].as_array().unwrap();
assert_eq!(players.len(), 1);
assert_eq!(players[0]["mc"], a.to_string());
assert_eq!(players[0]["gui"], "AQEAAAA=");
}
#[tokio::test]
async fn rejects_bad_input_and_uuid_theft() {
let s = server();
let mc = Uuid::from_u128(10);
let r = sync(&s, 1, json!({"server": "x", "mc": mc, "gui": "<bad>"})).await;
assert_eq!(r.status_code(), 400);
let r = sync(&s, 1, json!({"server": "", "mc": mc})).await;
assert_eq!(r.status_code(), 400);
assert_eq!(sync(&s, 1, json!({"server": "x", "mc": mc})).await.status_code(), 200);
let r = sync(&s, 2, json!({"server": "x", "mc": mc})).await;
assert_eq!(r.status_code(), 409);
}
#[tokio::test]
async fn read_only_poll_does_not_publish() {
let s = server();
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
sync(&s, 1, json!({"server": "x", "mc": a, "publish": false, "want": [b]})).await;
let r = sync(&s, 2, json!({"server": "x", "mc": b, "want": [a]})).await;
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
}

View file

@ -9,6 +9,7 @@ COPY common ./common
COPY accounts-service ./accounts-service
COPY gateway ./gateway
COPY configs-service ./configs-service
COPY chat-service ./chat-service
RUN cargo build --release -p configs-service
FROM debian:trixie-slim

View file

@ -97,6 +97,16 @@ services:
CONFIGS_PORT: 8082
networks: [lovisual-internal]
chat-service:
build:
context: .
dockerfile: chat-service/Dockerfile
restart: unless-stopped
env_file: .env
environment:
CHAT_PORT: 8083
networks: [lovisual-internal]
gateway:
build:
context: .
@ -107,12 +117,15 @@ services:
condition: service_started
configs-service:
condition: service_started
chat-service:
condition: service_started
env_file: .env
environment:
GATEWAY_PORT: 8080
ACCOUNTS_HTTP_URL: http://accounts-service:8081
ACCOUNTS_GRPC_URL: http://accounts-service:50051
CONFIGS_HTTP_URL: http://configs-service:8082
CHAT_HTTP_URL: http://chat-service:8083
# Read-only static files served under GET /downloads/* (lovisual.jar).
DOWNLOADS_DIR: /srv/downloads
volumes:

View file

@ -9,6 +9,7 @@ COPY common ./common
COPY accounts-service ./accounts-service
COPY gateway ./gateway
COPY configs-service ./configs-service
COPY chat-service ./chat-service
RUN cargo build --release -p gateway
FROM debian:trixie-slim

View file

@ -8,6 +8,7 @@ pub struct Config {
pub accounts_http_url: String,
pub accounts_grpc_url: String,
pub configs_http_url: String,
pub chat_http_url: String,
pub site_origin: String,
/// Directory served read-only under `GET /downloads/*` — today just
/// `lovisual.jar`, the mod's direct download (see TODO.md «Скачивание»).
@ -33,6 +34,7 @@ impl Config {
accounts_http_url: var("ACCOUNTS_HTTP_URL")?,
accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?,
configs_http_url: var("CONFIGS_HTTP_URL")?,
chat_http_url: var("CHAT_HTTP_URL")?,
site_origin: var("SITE_ORIGIN")?,
downloads_dir: std::env::var("DOWNLOADS_DIR").unwrap_or_else(|_| "downloads".into()),
trust_proxy: std::env::var("TRUST_PROXY").is_ok_and(|v| v == "true"),
@ -71,6 +73,7 @@ mod tests {
accounts_http_url: String::new(),
accounts_grpc_url: String::new(),
configs_http_url: String::new(),
chat_http_url: String::new(),
site_origin: "http://localhost:5173".into(),
downloads_dir: "downloads".into(),
trust_proxy: false,

View file

@ -31,6 +31,7 @@ pub struct Upstreams {
pub client: reqwest::Client,
pub accounts: String,
pub configs: String,
pub chat: String,
pub internal_key: HeaderValue,
}
@ -44,6 +45,7 @@ impl Upstreams {
.build()?,
accounts: cfg.accounts_http_url.trim_end_matches('/').to_owned(),
configs: cfg.configs_http_url.trim_end_matches('/').to_owned(),
chat: cfg.chat_http_url.trim_end_matches('/').to_owned(),
internal_key: HeaderValue::from_str(&cfg.internal_key)?,
})
}
@ -67,6 +69,7 @@ pub async fn proxy(State(up): State<Arc<Upstreams>>, req: Request) -> Response {
let base = match target {
Upstream::Accounts => &up.accounts,
Upstream::Configs => &up.configs,
Upstream::Chat => &up.chat,
};
let path_and_query = req.uri().path_and_query().map_or("/", |p| p.as_str());
let url = format!("{base}{path_and_query}");

View file

@ -2,12 +2,14 @@
pub enum Upstream {
Accounts,
Configs,
Chat,
}
pub fn upstream_for(path: &str) -> Option<Upstream> {
match path.trim_start_matches('/').split('/').next()? {
"auth" | "device" | "avatars" | "media" | "me" | "users" => Some(Upstream::Accounts),
"configs" | "showcase" => Some(Upstream::Configs),
"presence" => Some(Upstream::Chat),
_ => None,
}
}
@ -26,6 +28,7 @@ mod tests {
);
assert_eq!(upstream_for("/configs/shared/ABC"), Some(Upstream::Configs));
assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs));
assert_eq!(upstream_for("/presence/gui"), Some(Upstream::Chat));
assert_eq!(upstream_for("/authx"), None);
assert_eq!(upstream_for("/"), None);
assert_eq!(upstream_for("/health"), None);

View file

@ -60,6 +60,8 @@ pub fn rules() -> Vec<Rule> {
),
rule(Method::POST, "/avatars", Quota::per_hour(n(5)), Account),
rule(Method::GET, "/showcase*", Quota::per_minute(n(60)), Ip),
// The mod syncs menu presence about 3 times a second while a menu is open.
rule(Method::POST, "/presence/*", Quota::per_second(n(6)), Account),
]
}

View file

@ -20,6 +20,7 @@ pub fn config(accounts: &str, configs: &str) -> Config {
accounts_http_url: accounts.into(),
accounts_grpc_url: String::new(),
configs_http_url: configs.into(),
chat_http_url: String::new(),
site_origin: "http://localhost:5173".into(),
downloads_dir: std::env::temp_dir()
.join("lovisual-downloads-test")

View file

@ -3,7 +3,7 @@
# poking and scripts/e2e.sh. Restarts them if they are already running.
#
# Prerequisites (one-time):
# cargo build -p accounts-service -p configs-service -p gateway
# cargo build -p accounts-service -p configs-service -p chat-service -p gateway
# a Postgres with accounts_db + configs_db, and MinIO with the
# lovisual-avatars-test bucket (see backend/PLAN.md, Task 2).
# Dev-only secrets below are fine locally; production reads backend/.env.
@ -25,21 +25,22 @@ export COOKIE_SECURE="false"
# browser-facing avatar prefix served through the gateway (mirrors prod /api/media)
export AVATAR_PUBLIC_BASE_URL="http://127.0.0.1:8080/media"
pkill -f 'target/debug/(accounts-service|configs-service|gateway)' 2>/dev/null || true
pkill -f 'target/debug/(accounts-service|configs-service|chat-service|gateway)' 2>/dev/null || true
sleep 1
PORT=8081 GRPC_PORT=50051 setsid nohup "$B/target/debug/accounts-service" >"$L/accounts.log" 2>&1 &
CONFIGS_PORT=8082 setsid nohup "$B/target/debug/configs-service" >"$L/configs.log" 2>&1 &
CHAT_PORT=8083 setsid nohup "$B/target/debug/chat-service" >"$L/chat.log" 2>&1 &
GATEWAY_PORT=8080 ACCOUNTS_HTTP_URL="http://127.0.0.1:8081" \
CONFIGS_HTTP_URL="http://127.0.0.1:8082" SITE_ORIGIN="http://localhost:5173" \
CONFIGS_HTTP_URL="http://127.0.0.1:8082" CHAT_HTTP_URL="http://127.0.0.1:8083" SITE_ORIGIN="http://localhost:5173" \
setsid nohup "$B/target/debug/gateway" >"$L/gateway.log" 2>&1 &
up() { [ "$(curl -sf "$1" 2>/dev/null || true)" = "ok" ]; }
for i in $(seq 1 60); do
up http://127.0.0.1:8081/health && up http://127.0.0.1:8082/health && up http://127.0.0.1:8080/health && break
up http://127.0.0.1:8081/health && up http://127.0.0.1:8082/health && up http://127.0.0.1:8083/health && up http://127.0.0.1:8080/health && break
sleep 0.5
done
for u in 8081 8082 8080; do
for u in 8081 8082 8083 8080; do
up "http://127.0.0.1:$u/health" || { echo "port $u did not come up"; tail -5 "$L"/*.log; exit 1; }
done
echo "accounts+configs+gateway up"
echo "accounts+configs+chat+gateway up"

View file

@ -1,6 +1,7 @@
# LoVisual Mod (26.2): план и статус
Мод вырос из форка Combatant Client. Подробная история (фазы 1-12) лежит в git: `git log -p -- mod/TODO.md`.
Мод вырос из форка Combatant Client. Полный старый текст (фазы 1-12, все решения, коммиты, списки файлов) сохранён
в `docs/TODO_HISTORY.md` (770 строк, не правим); здесь только актуальное.
## Правила (обязательные)
- KISS / DRY / SOLID, без оверинжиниринга; публичный API менять можно, мёртвый код удалять.
@ -36,6 +37,13 @@
`DeathBody`, `DeathMesh`, `DeathEffectHelper`; тело из 6 частей с UV скина игрока, у мобов серые кубы). Настройки в
`DeathEffectSettings`. Не сделано: скрытие ванильного трупа на время эффекта, шейдер `death_blast`, режим Lightning из evo
(у нас уже есть свой Lightning).
- [x] GuiPresence (идея evo, наш бэкенд): `backend/chat-service` (новый крейт, in-memory, `POST /presence/gui`, привязка mc uuid к аккаунту,
TTL 6 с, план и протокол в `backend/chat-service/PLAN.md`), маршрут `/presence/*` в gateway (+ `CHAT_HTTP_URL`, лимит 6/с на аккаунт),
Dockerfile/compose/dev-stack. Мод: `platform/presence/{PresenceCodec,PresenceTimeline,PresenceClient}` (кодек 36 байт, плейбэк с
задержкой 300 мс, адаптивный опрос 350 мс/2 с), модуль `visuals/presence/GuiPresence` (делиться своим меню, показывать чужие,
дистанция, размер), снимок меню `ModulesMenuScreen.presenceSnapshot`. Мини-копия ClickGUI в мире перед игроком из цветных квадов
(без текста). Нужны привязанный аккаунт (`%link`) и мультиплеер. Не сделано: значок «LoVisual» у ника/в табе, названия модулей в панели,
просмотр видео (`.watch` у evo), деплой chat-service на прод (compose готов, нужен `docker compose up -d --build`).
- [ ] Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов; отчёт раздел 2.4). Шаги: `BedrockGeoParser` (общий с ножами),
`BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты.
Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`.
@ -53,6 +61,7 @@
- Режимы TargetESP Созвездие и Мечи; формы JumpCircles; кровь (лужи, яркость, depth); новые режимы KillEffect (ориентация
граней частей, UV скина, яркость искр); ProjectileTrails (вид ленты, глубина); Hero/orbs-меню.
- HoldMyItems Scripted: знаки осей `M:rotate`, видимость Lua-частиц, анимация квадов на реальной модели.
- GuiPresence: нужен запущенный стек (`backend/scripts/dev-stack.sh`) и два клиента с привязанными аккаунтами на одном сервере.
- Войсчат с настоящим SVC 2.6.24 (события mute/speaking, значок шапки HUD).
- Размер плашки описания в ClickGUI и авто-масштаб тултипа при GUI Scale 2/3/4.
@ -61,7 +70,15 @@
какой второй вид делать.
- ViewModel: свободный `Custom` swing и режимы sacura (Возвращение, Круговая); HitEffect: distortion/glow post-process;
JumpCircles: easing на появление; KillEffect: toggle «сквозь стены»; Trails: сравнение с sacura вживую.
- Фаза 8.5.3: около 150 файлов 250-400 строк и папки >4 файлов, чинить по мере касания.
- Фаза 8.5.3 (история: раздел 8.5): около 150 файлов 250-400 строк и папки >4 файлов. Правило: касаешься файла по другой
задаче, первым коммитом разбей на ≤250 (фасад + package-private хелперы, API вызывающих не трогать), вторым целевое изменение.
Очередь самых больших: LoVisualRenderPipelines, DraggableHudElementRegistry, Predictions, DynamicIsland, Module,
HudNotifier, AltManager, Triangulator, TargetESP, UiPrimitiveRenderer. Фасады (Renderer2D и т.п.) остаются исключением.
При переносах пакетов не ломать id модулей и ключи настроек (совместимость `.lvcfg`), после move проверять grep'ом.
- Разнос `addon`/`events`/`mixininterface` по смыслу отложен (54/41/88 файлов-импортёров, чистый import-churn): только скриптовым
отдельным проходом.
- Фаза 9 (FPS) закрыта: мёртвые грузы починены в коде, ручной модуль Optimize есть; авто-деградацию по FPS решено не делать.
- Фаза 7 (Delta QoL): Wasted и Arrows отменены, TapeMouse оставлен по решению владельца; список в истории.
- `.gitignore` для `server-plugin/.classpath`, `.project`, `.settings/`.
- Приёмка серверного плагина (Velocity + `%policy`), бэкенд chat-service и addons-registry (корневой TODO.md).

770
mod/docs/TODO_HISTORY.md Normal file
View file

@ -0,0 +1,770 @@
# LoVisual Mod (combatant-client-26.2) — план переписи
Мод — форк Combatant Client, переписываем под LoVisual.
## Правила (ОБЯЗАТЕЛЬНЫЕ, нарушение = откат)
- **KISS / DRY / SOLID** — простая структура, без оверинжиниринга
- **Макс. 250 строк на файл** (было >1500) — превышение = разбить на модули
- **Макс. 4 файла на папку** — больше = новая подпапка по смыслу
- **Публичный API можно менять свободно** (решение владельца 2026-09-05):
KISS важнее обратной совместимости; при рефакторинге разрешено менять
сигнатуры, удалять делегирование и мёртвый код, обновляя вызывающих
- **Коммиты — на английском** (решение 2026-09-23, действует с этого момента
вперёд; более старые коммиты на русском не переписываем)
- **Никакого статика** — `Minecraft.getInstance()` только лениво, никогда в поле
- **Никаких this-escape** — конструктор не публикует `this`
- **Никаких мёртвых миксинов** — миксин только если реально нужен; `cancellable=true` только при реальном cancel
- **Без пустышек** — файл без реализации не создаётся
- **Каждый этап компилируется** — `./gradlew build` перед переходом к следующему
- **Тесты обязательны** для новых модулей (JUnit)
---
## СТАТУС ПРОБЛЕМ (что вижу сейчас)
### 1. Ребрендинг не закончен (пункт 2)
- 288 файлов / **1464 упоминания** `combatant` в main-коде
- Классы: `CombatantRenderSystem`, `CombatantRenderEngineBootstrap`, `CombatantRenderPipelines`, `CombatantVulkanBackend`, `CombatantFrameGraph`, `CombatantTerrainVertex` и др.
- Заголовки файлов: «This file is part of the Combatant Client distribution» / копирайт изначального автора
### 2. Файлы-гиганты (пункт 3) — 12 файлов > 1500 строк
| Файл | Строк |
|------|-------|
| render/engine/renderer/Renderer2D.java | 3379 |
| features/gui/chat/BetterChatRenderer.java | 2870 |
| features/gui/clickgui/settings/UnifiedSettingRenderer.java | 2798 |
| features/module/modules/player/Scaffold.java | 2416 |
| features/module/modules/combat/AttributeSwap.java | 1922 |
| features/gui/clickgui/ClickGuiRenderer.java | 1900 |
| features/module/modules/visuals/NameTags.java | 1791 |
| features/module/modules/combat/ElytraTarget.java | 1711 |
| features/gui/hud/draggable/impl/TargetHud.java | 1687 |
| features/module/modules/visuals/ESP.java | 1520 |
| features/module/modules/combat/AutoCrystal.java | 1516 |
| features/gui/hud/nondraggable/impl/CustomBar.java | 1496 |
### 3. Runtime / JarReplacement (пункт 4)
- `runtime/` (ClientRuntime, RuntimeGate, ClientRuntimeState, SOFT_PANIC/JAR_REPLACEMENT_PANIC, shutdown/resume-участники)
- `runtime/loader/` (JarReplacementCoordinator, LoaderBridge, PanicOnlyJarReplacementCoordinator)
- **Моё предложение: выкинуть.** Живая замена jar в памяти — источник classloader-утечек, хрупкая, для клиента не нужна (обновление делает лаунчер). Заменить на простой Lifecycle.
### 4. Security (пункт 7)
- `features/security/BackdoorProtection` — на деле защита от SSRF/translate-инъекций (порт ExploitPreventer, MIT). Полезное, но `enum`-синглтон со статикой.
- `features/relations/StaffTracker` + `StaffHeuristicsMatcher` — детект «стафа» по эвристикам TAB. Сомнительно, решить оставить/выкинуть.
### 5. Тесты (пункт 6)
- 5 тестов на ~213k строк. Добавлять по мере переписи модулей.
---
## Фаза 1: Ребрендинг (пункт 2)
- [x] **Восстановлен `config/`-пакет** (55+3 файла) из `ref/zip/combatant-client-26.2 (1).zip` — он отсутствовал в форке, без него проект не собирался. Переименован `combatant.client.config` → `dev.loki.lovisual.config`. `compileJava` ✓
- [x] Заменить `combatant` → `lovisual` в main-коде (1464 вхождения, 288 файлов)
- [x] Переименовать классы `Combatant*` → `LoVisual*` (23 шт., включая `IrisCombatantFrameHooks` → `IrisLoVisualFrameHooks`)
- [x] Заголовки файлов → LoVisual, пересобрать копирайты (GPLv3 форка)
- [x] Проверить `fabric.mod.json` / `lovisual.mixins.json` на остатки
- [x] Проверить строки/сообщения/логи на «combatant»
- [x] JNI-имена в native: `Java_combatant_client_` → `Java_dev_loki_lovisual_` (34 шт.) + FindClass-пути
### Проверка: `rg -i combatant src` → 0 ✓, `./gradlew build` ✓ (1406 файлов изменено)
## Фаза 2: Выкинуть Runtime/JarReplacement (пункт 4)
> **Вместе с рантаймом удалить и модуль Panic** (`features/module/modules/misc/Panic.java`),
> интерфейс `RuntimeControlModule`, команду `RuntimeCommand` и все ветки на них
> в `ModuleManager`/`Module`. Panic — единственная реализация `RuntimeControlModule`,
> без него они становятся мёртвым кодом.
- [x] Удалить `runtime/` и `runtime/loader/` (14 файлов)
- [x] Удалить `Panic.java` + `RuntimeControlModule.java` + `RuntimeCommand.java`
- [x] Убрать ветки `instanceof RuntimeControlModule` и `tickRuntimeControllers()` из ModuleManager/Module
- [x] Убрать `RuntimeGate` из точек входа (LoVisual.java, модули, рендер) — заменён на `Lifecycle`
- [x] Заменить на простой Lifecycle-класс (`dev.loki.lovisual.Lifecycle`: INIT → ACTIVE → SHUTDOWN, гейты `canRunRender/Hud/ShaderBridge` + `RenderResourceReadiness`)
- [x] Panic-фичи удалены; `ModuleManager.disableAll()` оставлен как простая утилита
- [x] Мёртвые cleanup-методы удалены (`shutdownForRuntime`, `clearRuntimeReferences`, `VanillaChatRuntimeCleanup`, i18n-ключи panic/runtime)
### Проверка: `./gradlew build` ✓, тесты ✓ (13), `rg runtime-подсистема` → 0
## Фаза 3: Разбивка файлов-гигантов (пункт 3) — ЗАВЕРШЕНА
> Стратегия: публичный фасад исключён из лимита ≤250 (делегирует ~все публичные методы),
> вся логика вынесена в package-private final-хелперы в том же пакете (плоско),
> ≤250 строк каждый. 0 вызывающих файлов изменено. AttributeSwap/Scaffold/ElytraTarget/
> AutoCrystal удалены в Фазе 3.5 — их разбивать не нужно.
- [x] Renderer2D (3379) → фасад 2150 + 14 хелперов (Blur, Chamfered, Circles, Connectors,
Items, Lines, LiquidGlass, Orbiz, Polygons, Rects, RectsExtra, RectsGlow, SmokeFill, Textures)
- [x] BetterChatRenderer (2870) → фасад 210 + 21 хелпер (LayoutEngine, Viewport, FrameRenderer,
MessageRenderer, GlyphRenderer, InputRenderer, Suggestion*, InputHandler, MouseClick,
ContextMenu, Tooltip, Draw/Font/EditBox/Segment/CommandMerge + 3 model-файла)
- [x] UnifiedSettingRenderer (2798) → фасад 208 + 24 хелпера (по типам настроек + цвет-редактор
из 9 классов: Renderer, SquareRenderer, PresetRenderer/Handler, HexInput/State, Drag, Layout, Math)
- [x] ClickGuiRenderer (1887) → фасад 542 + 15 хелперов (Input, Keyboard, RenderEngine, Lifecycle,
Screen, TextBatch, DrawHelpers, KeyBind, Editor, PickerIcon, TopLayer, TabBar, TabState + Model + Fonts)
- [x] NameTags (1791) → фасад 438 + 11 хелперов (Renderer, Plate, LayoutEngine, Face, Equipment,
Opponent, Effects, LabelBuilder, EnchantFormatter, Cache + Model)
- [x] TargetHud (1655) → фасад 340 + 11 хелперов (Renderer, TargetResolver, Panel, Face, Content,
Text, Arc, Equipment, Effects, Formatter + Model)
- [x] ESP (1538) → фасад 269 + 11 хелперов (EntityCollector, ShaderRenderer/Mask/Color, 2D/3D,
SearchOverlay, Outline, MeshBuilder, ColorUtil + Model)
- [x] CustomBar (1495) → фасад 426 + 11 хелперов (XpBar, JumpBar, Locator*×7, Resolver + Model)
### Проверка: после каждого файла `./gradlew build` ✓; хелперы ≤250, фасады исключены;
### 0 вызывающих файлов изменено во всей Фазе 3
### Долг (файлы >250 вне списка гигантов, в т.ч. новые фасады):
### Renderer3D 433, Module 1181, LoVisualRenderPipelines 1329, DraggableHudElementRegistry 1449,
### Predictions 1363, DynamicIsland 1255, CustomHealthBar 412, CustomHotbar 547, HudNotifier 1116,
### Triangulator 1102, BetterButtons 1033, Themes 1012, SvgMeshBackend 1014, UiPrimitiveRenderer 1042,
### BetterTooltips 948, CustomTabList 829, InventorySwap 1009, ModuleManager 521, LoVisual 441,
### а также многие HUD/module/rendering-файлы 250–800 строк. Разбирать по желанию.
## Фаза 3.5: Удаление читерских модулей (легит-направление)
> Пользователь решил: LoVisual — легитное приложение. Все читерские модули удаляются.
- [x] combat: удалены AttributeSwap, AutoAnchor, AutoAttack, AutoBed, AutoBow, AutoCrystal, Backtrack,
Criticals, ElytraTarget, Hitbox, KillAura, KTLeave, MaceKill. **Оставлены:** ProjectilePuncher,
PvpCooldowns, Reach, RubberHand, SpearAssist, TPSSync
- [x] movement: удалены ВСЕ модули (AirJump, AutoDodge, ElytraFly, ElytraHelper, Flight, Freeze,
InventoryMove, NoFall, NoPush, NoStun, Parkour, Phase, ReverseStep, SafeWalk, Speed, Sprint,
Step, Strafe, SuperFirework, TargetStrafe, Timer, Velocity, WindJump) — 22 файла
- [x] player: удалены AirPlace, AutoArmor, AutoEat, AutoTotem, Scaffold, XCarry.
**Оставлены:** AutoTool, ClickPearl, FakeLag, NoDelay, NoInteract, ShitDropper
- [x] Осиротевшие хелпер-пакеты: удалены `combat/autocrystal/` (17), `combat/autobed/` (9),
`combat/autoanchor/` (9), `util/block/scaffold/` (10), `BlockPlacer` + тест ScaffoldPlacementGateTest
- [x] Enum-заглушки: `KillAura.RaycastMode/RotationTiming/AngleSmoothMode` → локальные enum в
ProjectilePuncher/Reach; `Criticals.SelectionMode` → удалено; `Hitbox`-зависимость убрана из ESP
- [x] ESP переписан под легит: новые настройки `only_visible` (LOS-луч от камеры), `skip_invisible`,
`only_armor` (без брони не подсвечивать) — по умолчанию `true`, только для игроков
### Проверка: `./gradlew build` ✓, тесты ✓ (13), остатки только в комментариях/строках
## Фаза 4: Security (пункт 7)
- [x] BackdoorProtection: убрать `enum`-синглтон, сделать инстансный сервис, ленивый `Minecraft.getInstance()`
- [x] Решить судьбу StaffTracker/StaffHeuristics — согласовать и выкинуть или переписать
(решение: StaffHeuristics удалён, StaffTracker+StaffData оставлены, переведены на INSTANCE-паттерн)
- [x] Покрыть логику фильтров тестами (BackdoorProtectionTest, 11 тестов)
## Фаза 5: Модули — нормализация под правила
- [x] Прочесать все 131 модуль: убрать `static final Minecraft` в полях (6 шт. уже найдено)
(LegacyHudNotifier, ScissorFunction, ShitDropper, ProtocolUtil, CooldownRender, ClickGuiRenderer.MC
+ 7 файлов-потребителей ClickGui-пакета — всё переведено на ленивый `Minecraft.getInstance()`)
- [x] Проверить модули на `System.currentTimeMillis()` → заменить на игровое время
(16 файлов модулей → новый `util/time/GameClock.millis()`, откат на стенное время вне мира)
- [ ] Разнести addon/events/mixininterface по смыслу — ОТЛОЖЕНО: blast-radius 54/41/88 файлов-импортёров,
чистый import-churn без функциональной ценности; делать только отдельным скриптовым проходом
## Фаза 6: Тесты (пункт 6)
- [x] Юнит-тесты: EventBus (12), конфиг (values: NumberValue/Boolean/String/Set/ItemIdSet/BooleanMap,
ModeValue, EnumValue, RGB/RGBA color; profile: DiffEngine, ValueCodec),
утилиты (LegacyTextUtil — 12). Игровые классы без моков: тесты только на чистую логику.
- [x] По мере переписи модулей — тест на каждый (Текущий итог: 292 теста, все зелёные)
## Фаза 7: QoL-модули из Delta (легит) — ПЛАН, обсуждаем
> Референс: `ref/delta-26.2-main` (чит-клиент, код обфусцирован — берём только идеи,
> переписываем под наши правила; у Delta 114 модулей, у нас 63).
> Уже перенесено: SeeInvisibles→InvisESP, HandsShader→Chams/Noise, AtmoDawnFog,
> ShulkerPreview, Animations, ItemPhysic (из WorldTweaks), HoldMyItems (фикс ordinal-бага,
> математика в EaseCurves). Реф-исходники: ref/hmi-src, ref/soupvisuals-src.
### Решения по обсуждению (2026-09-05)
- [x] Wasted — НЕ делаем
- [x] TapeMouse — делаем (пользователь: AFK-фермы, не чит-софт)
- [x] DeathCoords — делаем
- [x] ItemScroller / Arrows — НЕ делаем
- [x] ColorPlus ≈ наш PostFX — PostFX восстановлен и починен (дефолтный пресет был near-identity → добавлен Cinematic + тесты)
- [x] Правило папок: максимум 4 файла (было 3)
### Перенос (список пользователя)
- [x] **StreamerMode** (misc) — маскирует ники-подобные токены и названия серверов в чате
(общая точка NameProtect: ChatComponentMixin.addMessage), скины заменяются на
стандартные (AbstractClientPlayerMixin.getSkin). Чистая логика — `util/text/NameMasker`
(тест NameMaskerTest).
- [x] **LockSlot** (player, ~36) — запрет выброса из выбранных слотов 1–9 (анти-случайный Q),
опция «только в PVP», предупреждение в чат. Точка: перехват drop-пакета.
- [x] **AutoRespawn** (player, ~28) — авто-возрождение после смерти (tick на death screen).
- [x] **AutoLeave** (player) — выход по триггерам: низкое ХП, N игроков в радиусе
(друзья игнорируются опцией), задержка + предупреждение. Выход через
`mc.disconnect(null, false)`; чистая state-machine `LeavePolicy` (AutoLeaveTest).
- [x] **AutoAccept** (player) — авто-ответ на ТП/клан-запросы: паттерны фраз (SetValue),
команда ответа, «только друзья» (PlayerRelations), задержка, кулдаун ≥500мс.
Пакетный путь: PvpChatEvent (handleSystemChat/handlePlayerChat), отправка
`connection.sendCommand(...)`. Чистый парсер `TeleportRequestParser` (AutoAcceptTest).
- [x] **SoundReducer** (player, ~42) — РЕШЕНО: развитие NoSound, а не новый модуль.
Режимы Mute (старое поведение через ClientPacketListenerMixin) и Reduce —
новая громкость 1–100% через SoundEngineMixin (@Redirect getVolume в
SoundEngine.play). Чистая математика `NoSound.resolveScale` (NoSoundTest).
- [x] **Wasted** (misc, ~100) — ОТМЕНЕНО (решение пользователя)
- [x] **ThirdPerson** (player) — свободный обзор от 3-го лица по удержанию бинда (L,
настраивается), движение игрока не поворачивается. MouseMixin.turnPlayer копит
orbit-yaw/pitch, CameraMixin.update (TAIL) ставит камеру за игрока по орбите
(+ cull-frustum). Орбиты сбрасываются, когда бинд отпущен.
- [x] **UseTracker** (player) — уведомления: мои тотемы (N осталось), тотемы других,
эффекты оппонентов (группа: invisibility/speed/strength/regeneration/fire_res/weakness).
Пакетный путь: ClientboundEntityEventPacket (event 35) + ClientboundUpdateMobEffectPacket.
Тест UseTrackerTest.
- [x] **NoServerPack** (misc) — авто-отклонение принудительного серверного ресурспака
(спрашивать/отклонять всегда). Точка: ClientCommonPacketListenerImplMixin.
- [x] **NoFriendDamage** (combat, ~19) — отмена атаки, если цель — друг (PlayerRelations).
- [x] **TapeMouse** — ОСТАВЛЕН (решение владельца 2026-09-08): автоклик по таймеру
(Attack/Use) для AFK-ферм, интервал, опция «только при зажатии». Считаем
допустимым исключением из Фазы 3.5 по явному решению владельца.
### Кандидаты из отчёта (не в списке пользователя — обсудить)
- [x] **DeathCoords** (~27) — координаты последней смерти в чат. Дёшево, полезно.
- [x] **ItemScroller** (misc) — shift-перекладка предметов колёсиком в контейнерах:
QUICK_MOVE клик по слоту под курсором, иначе слот хотбара (опция).
Точка: AbstractContainerScreenMixin.mouseScrolled; чистая логика
`ScrollMovePolicy` (ScrollMovePolicyTest).
- [x] **Arrows / off-screen индикаторы** — ОТМЕНЕНО (решение владельца 2026-09-08).
### Проверка после каждого модуля: `./gradlew build` ✓, тесты на чистую логику ✓
## Фаза 7.5: Команды — префикс % + автокомплит
- [x] Префикс команд сменён `@` → **`%`** (старый `@` принимается как legacy).
Источник истины — `features/command/CommandPrefix` (CHAR/STRING/isCommandLike/strip);
вызовы обновлены: CommandManager.handle/suggest, ScreenMixin (RunCommand),
BetterChat (TabHandler, SuggestionState, SuggestionRenderer, ContextMenuHandler),
usage-строки и RunCommand-ссылки всех команд (help/modules и др.).
- [x] Аргументный автокомплит дополнен usage-подсказками: если `suggest()` команды
не дал вариантов на позиции — показывается подсказка вида `%toggle <module>`
(`features/command/UsageHints`, парсит `<...>`/`[...]` из usage-строки).
Существующий UI автокомплита BetterChat (стрелки/Tab/клик) работает как раньше.
### Проверка: `./gradlew build` ✓, `./gradlew test` ✓ (CommandPrefixTest, UsageHintsTest,
### CommandDiscoveryTest обновлён)
## Фаза 8: Структура «как у Delta» (маленькие файлы) — ПЛАН
> Правило ≤250 уже есть (Фаза 1–3), но долг: ~280 файлов >250 строк.
> У Delta файлы компактные и слои разделены: api / client(features, ui, utils, processors) /
> inject(accessors, invokers, mixin). Наша пакетная структура сопоставима — проблема в размере файлов.
- [ ] Топ-гигантов к разбивке (после Фазы 3): LoVisualRenderPipelines 1527,
DraggableHudElementRegistry 1449, Predictions 1363, DynamicIsland 1254, Module 1181,
HudNotifier 1116, AltManager 1115, Triangulator 1102, TargetESP 1061, UiPrimitiveRenderer 1042
- [ ] Стратегия: как в Фазе 3 — фасад + package-private хелперы ≤250, 0 изменений вызывающих,
`./gradlew build` после каждого; по мере касания файлов в Фазе 7 — разбивать на ходу
- [ ] Facades >250 (Renderer2D 2150 и т.п.) — оставить как есть (исключение по правилам)
- [ ] Не смешивать с Фазой 7: разбивка структуры — отдельными проходами, иначе diff-каша
### Проверка: `./gradlew test` зелёный
## Фаза 8.5: Аудит структуры (правила ≤250 строк / ≤4 файла) — ПОЛНЫЙ СПИСОК НА РЕФАКТОРИНГ
> Снимок 2026-09-09 (`src/main/java`, 1459 файлов / 197k строк).
> **Гейт перед Фазой 9.2 (Optimize)**: движковые файлы, которые Optimize будет трогать,
> должны быть разбиты заранее. 9.1 (мёртвые грузы) от этого НЕ зависит — идёт своим порядком.
> Стратегия та же, что Фаза 3/8: фасад + package-private хелперы ≤250, 0 правок вызывающих,
> билд после каждого файла. Фасад-исключение (Renderer2D) — см. оговорку ниже.
### Итоговая картина
- **Файлы >250 строк: 244 шт.** → >800 строк: 26 | 400–800: 78 | 201–400: 140
- **Папки >4 файлов: 86 шт.**
- Разбивка больших файлов по зонам: features/gui — 73 | render/engine — 54 |
features/module — 35 | util — ~35 | mixins — 3 | config — ~6 | прочее — остальное
### 8.5.1 Папки >4 файлов — топ должников (разбить на подпапки по смыслу)
- [x] `mixins/` — 108 файлов → leaf-пакеты (entity/blaze3d/input/world/network/gui/hud/render),
mixins.json перегенерирован, FQN-строки LoVisualMixinPlugin обновлены. Коммиты 94d5af7, 3c76817.
- [x] `mixins/accessors/` — 45 → 12 групп (entity/renderer/world/state/packet/inventory/...). Коммит 3c76817.
Также разбиты iris/sodium/security на подпакеты.
- [x] `features/module/modules/visuals/` — 67 → esp/nametags/hats/effects/weather/scene/camera/
player/items/fx + lyrics/lrc. Конфиг-id НЕ менялись (проверено). Коммит 073ac26.
- [x] `features/gui/clickgui/settings/` — 51 → core/bool/mode/slider/text/text-list/group/bind/
color(+render/preset/hex)/i18n/render/skin/owner/cooldown/util. Коммит 9db1fec.
- [x] `features/gui/hud/draggable/impl/` — 40 → stats/metrics/status/combat/panels/targethud(+/render,
render/parts)/scripted + hudnotifier/model, targethud-модель свёрнута в класс с record-ами. Коммит 6b89e40.
- [x] `events/impl/` — 32 → input/movement/world/network/entity/pvp/render/player;
events/ верхний уровень: core(Event,EventBus,Events) + meta(EventHandler,UsedImplicitly).
- [x] `features/gui/chat/` — 31 → `render/`, `suggestions/`, `menu/`, `model/`.
- [x] `render/engine/uniform/impl/` — 28 → по потребителям (`uniform/impl/pipeline/`, `uniform/impl/effects/`).
- [x] `render/engine/renderer/ui/` — 28: верхний уровень разнести по уже существующим
`draw/`, `runtime/`, добавить `blur/`, `batches/`.
- [x] `render/engine/text/` — 24 и `text/backend/` — 12 → `text/` разбить на `text/render/`,
`text/layout/`, `text/msdf/`.
- [x] `render/engine/renderer/ui/runtime/script/` — 23, `ui/draw/` — 23 → внутри по 4-файловым кластерам.
- [x] `features/gui/hud/nondraggable/impl/` — 20 (+`impl/tab/`) → `impl/overlays/`, `impl/tablist/`.
- [x] `features/gui/clickgui/` — 20 на верхнем уровне → всё в существующие `layout/`, `sections/`,
`picker/`, `protocol/`, `util/`.
- [x] `render/engine/command/` — 17, `render/helpers/` — 16, `features/command/impl/` — 16,
`config/values/` — 16 → по 2-3 подпапки (типам: `values/numeric/`, `values/color/`…).
- [x] `render/engine/profiler/` — 14, `modules/player/` — 14, `features/gui/hud/` — 14,
`addon/` — 14 — средние: раскидать при касании.
- [x] Хвост (5–13 файлов, ~55 папок): `util/pvp` 13, `util/projectile` 13, `render/iris` 13,
`config/profile` 13, `features/module/misc` 12, `features/module` 12, `config` 12 и т.д. —
ЗАКРЫТ выделенным проходом (коммиты 8846d53…303b17e, волна 8.5.1 завершена).
Снимок после волны: 0 папок >4 файлов в `src/main/java` (скан 2026-09-10).
Замечания: command-discovery рекурсивный (ClassGraph acceptPackages), конфиг-id
и ключи i18n не менялись; package-private члены, ставшие кросс-пакетными,
расширены до public/protected; svgMsdfTool-импорты починены после split svg.
### 8.5.2 Файлы-гиганты >600 строк — приоритетная очередь разбивки (53 файла) — ЗАВЕРШЕНА
> Проверено 2026-09-28: `find src/main/java -name "*.java" | xargs wc -l` → 0 файлов >600 строк
> (самый крупный — Renderer2D.java 474, осознанный фасад-делегат, исключение Фазы 8). Все 53
> пункта очереди ниже закрыты. Остаток структурного долга — 8.5.3 (201–400 строк), не марафон,
> а правило «трогаешь файл по другой задаче — первым коммитом режешь до ≤250».
> Формат: файл (строк) → идея разбивки. Порядок сверху вниз. Facade-оговорка:
> Renderer2D остаётся фасадом-делегатом (правило Фазы 8), но его вспомогательные
> классы (kawase/blur/glass) — вынести из OrderedUiBatcher (935) в `ui/blur/`.
- [x] Renderer2D 2179→1597 → ГОТОВО 7b848ddd+464f7868+098c5515 (2026-09-25): ФАСАД;
далее 1597→475 наследованием (волна 2026-09-25 ниже)
(исключение по правилу Фазы 8) — делегирование оставлено; −234 мёртвые шимы
совместимости, −271 недостижимые перегрузки liquidGlass/item/Corners,
−115 прочие мёртвые перегрузки+импорты; макс. тело метода 35 строк (блоков >250 нет),
хелперы в `renderer/renderer2d/` (Rects2D/Strokes2D/…), build+test ✓
- [x] LoVisualAltManagerScreen 1113 → ГОТОВО (2026-09-10): 11 файлов `mainmenu/alt/`
(Screen 132, Controller 250, Ops 59, Anims 108 + auth/ 171 + render/ 157/156/139/100
+ render/cards/ 178/166), все ≤250, build+test ✓
- [x] UiPrimitiveRenderer 1053 → ГОТОВО (2026-09-10): фасад 188 (коннекторы) +
render/primitive/ 4 (Context 153, Props 158, Painter 145, GlowPainter 147) +
render/rect/ 2 (RectPainter 150, CornerPainter 150), build+test ✓
- [x] BetterButtons 1032 → ГОТОВО 7e86f8d: 388 + 7×≤250 (state/hover/parallax/queue/render) — extraction+tooltip+render → `hud/nondraggable/impl/buttons/`
- [x] InventorySwap 1031 → УДАЛЁН 5acafc8: чит-инфра, заменён ShitEsp + хотбар-only — по стратегиям свапа; чистая логика отдельными классами с тестами
- [x] SvgMeshBackend 1014 → ГОТОВО c11ebe3/aafe7de: 1020→267 + Parser181/Geometry173/Texture223/Render35/Cache25 — parser/triangulate/cache разнести
- [x] Themes 1012 → ГОТОВО e51a610: 1012→562 + 3×163 пресета — реестр тем в `theme/presets/` (по 4 темы/файл), дефолты отдельно
- [x] WorldParticles 991 → ГОТОВО 1d757a6+5a463d7e: 999→821→260 (фасад) + ambient/render/ 3×≤182
(Batches/Bubble/Funnel) + ambient/particle/ 197 + geometry/ доработаны (offset/multiplyAlpha/STACKS) —
типы частиц в `visuals/effects/ambient/`
- [x] Module 989 → ГОТОВО c8da336: 1031→139 + 4×≤134 (ext/) — базовый класс: settings-механику вынести в `module/ext/` (осторожно, общий предок!)
- [x] LoVisualAddonManagerScreen 989 → ГОТОВО de56b4b9: 989→112 + 10 файлов mainmenu/addon/
(Controller 164, Ops 112, PanelAnims 56, auth/AddonScanState 52 + render/ 115/162/183
+ render/cards/ 79/88), все ≤250 — по образцу AltManager
- [x] Chams 976 → ГОТОВО 78b5335: 985→129 + 4×≤195 — режимы рендера в подклассы-политики (фасад модуля тонкий)
- [x] ClickGuiPickerState 962 → ГОТОВО 234f0d9: 970→166 + 4×≤140; CombatProtocolHeuristicsEditorState 932→79 +4×≤192 03ae4a6 — state/actions хелперы
- [x] ModulesMenuScreen 960 → ГОТОВО 234f0d9: 966→145 + 4×≤178 — рендер категорий отдельно от состояния
- [x] UiMeshGeometry 954 → ГОТОВО sub-agent: 956→99 + 4×≤135 — по примитивам геометрии
- [x] BetterTooltips 953 → ГОТОВО 0b8ea41: 960→181 +4×≤199; OrderedUiBatcher 935 → ГОТОВО 6e0bf7f: 944→188 (→ batches + blur), Scoreboard 924,
Rain 893 → ГОТОВО sub-agent: 903→159 +4×≤162; CompactHudStatModel 893 → ГОТОВО 8979140: 894→125 +4×≤175; TazikHat 859 ✓ ✓ закрыт портированием в
Cosmetics built-in (2026-09-11, см. Фазу 7),
ShaderPatchCompiler 843 → ГОТОВО 2b55fbc: 843→66+4×≤174; OnlineRelationPlayerPickerComponent 837→74+4×≤192 17fc665; CustomTabList 830 → ГОТОВО 9e9b71d: 834→136+4×≤166,
GameRendererMixin 830 → ГОТОВО f45f46e: 830→344 (только хуки+Host/BobInvoker) +
mixins/render/main/handlers/ 4×≤250 (ProjectionHelper/WorldEngine/WorldPost/WorldDebug), Predictions 805 → ГОТОВО (см. хвост)
- [x] Хвост 400–800: ЧАСТИЧНО — ConfigProfilesComponent 799→125 d95bfec, HitEffect 770→120 5e1ce05, PlayerMovementSimulation 757→157 4b253d1, ProjectilePuncher 747→104 fba4f7d, MediaPlayer 739→174 8ce3927, Crosshair 737→143 4cf0970, Itemizer 699→193/Cooldowns 707→151 11245ac, Potions 617→176 2e4912a, Admins 702→118 f18cb96, Keybinds 611→157 cc18feb, ModuleList 499→181 52327dc, Radar 392→161 f580373, Fps 309→168 5df2cc0, Memory 304→151 de7cc2b, Tps 339→154 e167f0c, Ping 309→145 15d04cb, SpeedBps 334→156 b78a638, GameTime 314→150 922d3bc, SystemTime 300→136 3cb6e40, Coordinates 337→174 9412982, Armor 324→162 932639d, Inventory 476→153 f4124f4, BlockHighlight 692→271+4×≤250 9f76e5a5 (scene/highlight/), см. /tmp-снимок в истории команд: ConfigProfilesComponent 791,
HitEffect 770, PlayerMovementSimulation 755, ProjectilePuncher 746, MediaPlayer 739,
Crosshair 737, MeshBuilder 729, SettingsPanelComponent 703, Admins 702, Cooldowns 701,
Itemizer 699, LocalPlayerMixin 687, Trails 681, ItemBatchRenderer 673,
CosmeticModel 671, MenuScreen 667, GlStencilFramebufferSupport 663, ConfigSerializer 663,
DynamicIsland 661, ReimaginedVisual 660, RelationsComponent 659, UiStyle 640, Potions 617,
ModuleComponent 612, MsdfFont 606, Keybinds 603, MediaSessionService 594 и др.)
- [x] Волна 2026-09-24 (HUD/settings): NameTags 453→223 (nametags/impl/ Keys+Collector+Projection) b1414d7a,
MainSettingsComponent 448→291 (+Scroll+Model) 3f0d452f, LinuxMediaSession 441→148 3d11bb4e,
CustomTextRenderer 229→195 (GradientTexts), CustomBar 438→185 (+CustomBarModel константы
+bars/impl/BarSettings, forwardеры удалены, mixins правлены на реальные рендереры) 4e442fb7,
CustomHealthBar 406→146 (+bars/impl/HealthBarPainter+HealthScriptRenderer, dead-code удалён) a3415435.
bars/ закрыт: все файлы ≤250, impl/ = 3 файла.
- [x] Волна 2026-09-25 (реестры): Renderer2D 2179→1597 (см. строку 8.5.2 выше,
7b848ddd+464f7868+098c5515), LoVisualRenderPipelines 432→311 9577ed3d
(SHADER_* 105 констант → pipeline/ShaderIds 130, мёртвые импорты удалены;
логика ≤26 строк, фасад-исключение), build+test ✓.
- [x] Волна 2026-09-25 (наследование фасада): Renderer2D 1597→475 (leaf), цепочка
Renderer2D extends Item(124) extends Glass(183) extends Shapes(313) extends
Path(245) extends Rounded(315) extends Base(23); API вызывающих не менялся
(0 правок callers, static-import ITEM_OVERLAY_* и Renderer2D.LiquidGlassPreset
работают по наследству); renderer/facade/ = 4 файла, facade/shape/ = 2 ✓ лимит;
Base.self() для this→Renderer2D-делегатов; семейства >250 — фасад-части
(исключение TODO:Facades); 37c7c410+64e23ef8+9ae72eeb+2b8c40a3+6204017b+
9fffc58d, build+test+checkstyle ✓.
### 8.5.3 201–400 строк (~140 файлов) — проход «касаться = чинить»
- [x] Волна 2026-09-30 (топ-4 по объёму, выбор владельца): каждый файл — отдельный
коммит, фасад + хелперы, публичный API и вызывающие не тронуты:
ConfigDiffPanelComponent 393→167 (фасад + `config/diff/`
HeaderRenderer/BodyRenderer/Scrollbar) 7e8b6b64;
RotationManager 391→199 (фасад + `rotation/internal/`
RotationState/RotationStepper/RotationPackets, @EventHandler-методы остались
на фасаде — регистрация не менялась) 3e57542b;
ScriptedTriangulatorHudPanel 386→189 (записи Panel/Palette остались
вложенными → `ScriptedTriangulatorHudPanel.Panel` у вызывающих работает,
рендер → ScriptedTriangulatorRenderer, пропсы/сигнатуры → `scripted/panel/`)
76bd8e95; BetterButtons 381→197 (настройки → ButtonStyleSettings по образцу
BarSettings, иконки → ButtonIconRenderer, −3 мёртвых метода без вызовов)
71f779c8; `./gradlew build` ✓ test 438 ✓ checkFolderLimit ✓ на каждом шаге;
файлов >250: 155 → 151
- [ ] Не делать отдельным марафоном: при правке файла по другой фазе — первым коммитом
разбивка на ≤250, вторым — целевое изменение (иначе diff-каша, правило Фазы 8)
### 8.5.4 Порядок и критерии готовности
- [x] Очередь: 8.5.1 крупные папки (mixins, visuals, clickgui/settings, hud impl) →
8.5.2 гиганты >800 → далее по убыванию. Каждая папка/файл = отдельный коммит.
→ 8.5.1 ЗАВЕРШЕНА (2026-09-10): 0 папок >4 файлов.
- [ ] После каждого шага: `./gradlew build` ✓, `./gradlew test` ✓; конфиг-id модулей и
ключи сеттингов НЕ ломаем (совместимость .lvcfg); скрипты-переименования пакетов
не трогают string-literals id — проверять grep'ом после moves.
- [x] Критерий закрытия для старта 9.2: ноль файлов >250 в путях, которые трогает Optimize
(Renderer2D-секции blur/glass, OrderedUiBatcher, HUD-имплементации bgEffect,
MsdfFont/CustomTextRenderer), и ноль папок >4 в этих же пакетах. Полный ноль по всем
244/86 — долгий хвост, 9.2 им не блокируется.
→ 2026-09-24: MsdfFont 155 ✓, OrderedUiBatcher 180 ✓, CustomTextRenderer 229→195 ✓
(glyph-pass вынесен в GradientTexts). Остаток гейта — bgEffect-HUD >250:
TargetHud 333, Triangulator 304, Scoreboard 233 — ВСЕ фасады Фазы 3 с helper-пакетами
(targethud/render/, triangulator/{render,Solver,Tracker,View}, panels/scoreboard/) →
исключение по правилу фасадов. ГЕЙТ 9.2 ЗАКРЫТ.
- [ ] Обновить этот раздел при подходе к волне (отмечать [x], доснимать счётчики).
## Фаза 9: Оптимизация FPS (аудит 2026-09-09) — ПЛАН
> Диагноз: у клиента есть «налог каждый кадр», который платится даже при
> выключенных модулях (друг: просадка FPS на чистом конфиге, Windows).
> Решение владельца: мёртвые грузы — ЧИНИМ в коде; авто-деградации по FPS НЕ ДЕЛАЕМ;
> вместо неё — ручной модуль **Optimize** (включил → порезали стекло/blur/тяжёлые проходы).
### 9.1 Мёртвые грузы (чиним в коде всегда, независимо от модулей) — приоритет
- [x] **glGetError на каждом render pass** — ГОТОВО 2026-09-11 (коммит 137121e): гейт по clip-active || stencil-debug — `GlStencilShapeClipBackend.endRenderPass`
(`:140`) и `beginRenderPass` (`:98/:104/:110/:124`) дёргают `checkGlErrors`
(`:428-434`) на КАЖДЫЙ проход (миксин `CommandEncoderMixin:102-112` висит на
submitRenderPass → это и все ванильные/Sodium проходы). `glGetError` = синк с
Windows-драйвером, несколько мс/кадр. Фикс: проверять только если
`mode != DISABLED || renderPassAttachmentRequired`, либо под dev-флагом;
в релизе — no-op.
- [x] **`lovisual$isVulkanBackend()` на каждый submitRenderPass** — ГОТОВО (c9a65b1), кэш Boolean
(`CommandEncoderMixin:66-74`) — `getDeviceInfo().backendName().toLowerCase()`
заново на кадр×проходы. Фикс: вычислить один раз, закэшировать boolean.
- [x] **PreTranslucentDepth — полноэкранный depth-copy каждый кадр без нужды** — ГОТОВО (b747b47): demand-флаг requestForNextFrame()/takeRequested() у потребителей getDepthView(For), capture в vanilla- и Iris-путях по запросу (1-кадр задержка, фолбэк на live view) —
`LevelRendererMixin:275-283` гейтится только «нет Iris». Фикс: гейт
`needsPreTranslucentDepthCapture()` по образцу `WorldSceneDepth`
(`LevelRendererMixin:160-218`); потребители: `PostProcessGraphResources:61`,
`LoVisualVisuals:79`, `WorldPassCompiler:174`.
- [x] **copyMainColor каждый кадр ДО гейта** — ГОТОВО (118eb2a): ранний return по liquidGlassBlurRequested — `UiBlurResources.captureWorldSource`
(`:116-123`): полноэкранный color-copy делается до проверки
`liquidGlassBlurRequested` (`:122`). Фикс: переставить — copy только когда
blur реально запрошен.
- [x] **Строки профайлера при выключенном профайлере** — ГОТОВО (aa55c4d): 2-arg scope()/section()/postPass(prefix,suffix) в ProfilerPhase/RenderProfiler2D/3D/RenderCostProfiler (DEV-гейт конкатенации) — конкатенации на call-site
каждый кадр: `PostProcessGraph:59` (`"graph:" + phase`), `ModuleManager:219-221/:290`
(`"3d:module:" + m.name()`). Фикс: ленивые supplier'ы / ранний isEnabled-check.
- [x] **Мелочь, по ходу**: `new PoseStack`/`new Matrix4f`×2/`new Frustum`×2 в кадре
(`GameRendererMixin:203-239/:341-347/:609-617`) — закэшить переиспользуемые копии. Frustum-часть отменена (da6525c): @Invoker("calculateFrustum") не резолвится на remapped-рантайме = краш; scratch-кэш матриц/PoseStack остался.
### 9.2 Модуль **Optimize** (misc) — ручной рубильник экономии
> ⛔ ГЕЙТ: не начинать, пока не закрыта Фаза 8.5 (рефакторинг-аудитStructure ниже) —
> Optimize трогает Renderer2D/OrderedUiBatcher/HUD-имплементации, все они >250 строк.
> Идея владельца: один тумблер «хочу максимум FPS». НИКАКОГО автовключения по FPS.
> Пока активен — движок и HUD работают в лёгком режиме.
- [x] Каркас — ГОТОВО ade82f06: `modules/misc/optimize/Optimize.java` (в подпапке — лимит
`checkFolderLimit`=4 файла в `misc/`), 4 bool-тумблера (default on), тест `OptimizeTest`.
Отклонения от плана при реализации:
- `no_glass` — **9246e010**: не мутим bgEffect (конфиг элементов неизменяем) и не
ищем `prepareSharedBlurInternal` (метода больше нет). Гейты: `Blur2D.source()`
→ null при no_glass (вместе с `FpsOptimizer.isGlassDisabled` → все
blurRect/glassBlurRect мимо) + ранние return в
`UiBlurResources.requestLiquidGlassBlur/requestBeforeNextShapeClip`
(флаги не ставятся → `captureWorldSource` early-out → нет полноэкранного copy,
kawase и prewarm). Побочный эффект: гасит и низкоконтрастный blur текста
(осознанно — «все glass/blur off» по замыслу тумблера).
- `lite_post` — **52463246**: MotionBlur `SHADER_TAPS=11 → LITE_TAPS=5`
(план «15→5» устарел — тапов уже 11), `historyValid = hasHistory && !lite`
(temporal-история off = ещё ~10 neighborhood-fetch'ей в шейдерный early-out;
цена: хвостов истории нет, остаётся чисто пространственный блюр).
DoF: `taps = lite ? LOW : quality` (LOW=8; план «32→8» устарел — квалити 8/14/22).
- `lite_sky` — **c403cb53**: `SkyboxShaderPasses.LITE_SKY_LAYER_MASK=0xF00F`
(базовые слои 0-3 + звёзды 12-15, чистит nebula/curtains/bursts/polar/water/…),
`layerMask &=` при включённом тумблере; в `reimagined_skybox.frag` 4
безусловных fbm + bursts-блок обёрнуты в layer-гейты; pulsar/thunder fbm
4→2 октавы через `u_Thunder.w` (ранее всегда 0). Тест `SkyboxShaderPassesTest`,
все 3 шейдера проходят `glslangValidator -S frag`.
- `lean_hud` — **8746074d**: `GradientTexts.horizontalFade` → plain-текст (без
per-glyph `new String` и gradient-лямбд; marquee-clipped — clip остаётся,
fade-градиенты гаснут) в Custom и Vanilla-реализациях; Fps digit-roll off
(`FpsAnimator.widerText/digitProgress` → settled-прогресс).
Не в scope: `GradientTexts.gradient/quadGradient` (визуал списка модулей),
аллокация в `HorizontalFade.glyphAdvance` (её просто обходит lean-гейт).
- [x] Механизм «как движок узнаёт» — ГОТОВО ade82f06: `render/engine/optimize/
OptimizeState.java` (enum-utility, 4 volatile boolean, `apply/reset`, чистый
статик без состояния модуля), движок читает геттеры, модуль пушит в onEnable/onFrame.
- [x] Взаимодействие с 9.1 — ГОТОВО: фиксы 9.1 не завязаны на Optimize и продолжают
работать всегда; Optimize только добавляет гейты сверху.
- [x] i18n en_us + ru_ru — ГОТОВО ade82f06: `module.optimize.*` + `setting.optimize.*`
(4 ключа, симметрия en/ru проверена).
- [x] Нотификация при первом включении — ГОТОВО ade82f06: once-флаг в инстансе модуля +
`Notifier.info("Optimize: " + summaryOf(...))` со списком включённых тумблеров.
### 9.3 Проверка
- [x] `./gradlew build` после каждого пункта; `./gradlew test` зелёный — выполнялось
после каждого из 5 коммитов 9.2 (итог: build+test зелёные на 8746074d).
- [x] Порядок: 9.1 (по одному пункту на коммит) → 9.2 — 9.1 закрыта (коммиты выше),
9.2 закрыта ade82f06..8746074d.
- A/B-мерка до/после 9.1 на чистом конфиге — УБРАНА из плана (решение владельца
2026-09-25): требует запуска Minecraft на Windows, задачей не является.
## Фаза 10: Заход по референсам из Downloads (2026-09-15)
> Пользователь скинул 5 архивов, распакованы в `/tmp/lv_refs/`. Порядок выполнения —
> сверху вниз, по одному коммиту на пункт, `./gradlew build`+`test` после каждого.
- [x] **TrollfaceMask — почини "не работает"** (`/tmp/lv_refs/trollface/TrollfaceMask.java`,
референс Moonward 1.21.4). Наш модуль: `trollface/TrollfaceMask.java`.
Причина бага: биллборд ориентировался к камере (headPos→camPos); от первого лица
камера почти совпадает по вертикали с головой → view≈(0,1,0) → right=view×up
вырождается в 0 → плоскость ложится горизонтально, не видна. Режимы «Друзей»/«Всех»
брали только `TargetManager.getTarget()` (сущность под прицелом) — маска рисовалась
максимум на одном игроке. Фикс: ориентация по `getViewVector()` цели вместо камеры,
перебор `mc.level.players()` с фильтром по режиму (себя/friend via PlayerRelations/все).
Коммит e7e0ff2.
- [x] **Rain — сравнение с двумя референсами (2026-09-15, вывод: у нас уже паритет/лучше).**
Референс 1 `/tmp/lv_refs/rainfull/` (Cataclysm client) — наш `Rain.java` (894 строки) уже
является портом этого самого класса (Drop/Splash/Droplet/Mist, presetDensity/Wind/Mass,
drawStreaks/Ripples/Crowns/Droplets/Mist — 1:1 совпадающие константы и формулы), ПЛЮС у нас
уже есть их wet-surface screen-space постфх (`WetSurfaceUniforms` + шейдер
`assets/lovisual/shaders/wet_surface.frag`, pipeline `WET_SURFACE`, реализован как
`PostProcessPass` в самом Rain.java) — то, что в референсе вынесено в отдельный
`WetSurfaceRenderer`. Улучшать нечего, разница только в деталях реализации пайплайна.
Референс 2 `/tmp/lv_refs/mokriyzhir/` (`WetWorld.java`, пакет `ez.redux...`) — это ОТДЕЛЬНЫЙ
самостоятельный модуль "Wet World" из чужого чит-клиента (Category.VISUALS), НЕ часть дождя:
мочит поверхности всего мира по реальной погоде сервера (`world.getRainGradient`), с настройками
Quality (Low/Medium/High/Ultra), Reflection Strength, Wet Darkening, Ripples+Speed, Rain Only,
учитывает fog (интеграция с их модулем Fog: цвет/дистанция/яркость подмешивается в шейдер).
У нас такого отдельного всегда-активного "мир мокрый по факту дождя" модуля нет — наш
wet-surface жёстко завязан на модуль Rain (работает только когда сам Rain включён). Если
захочется — можно вынести wet-surface-pass Rain.java в отдельный модуль WetWorld с привязкой
к `mc.level.isRaining()`+`getRainLevel(tickDelta)` вместо `onlyWhenRaining` галки, плюс quality-
пресет и fog-интеграцию. НЕ делаю сейчас — не запрошено явно, чисто идея на будущее.
- [x] **WetWorld — сделан отдельным модулем (2026-09-15), шейдер Rain пофикшен.**
Третий референс `/tmp/lv_refs/wetworld2/` (`ru.white...WetWorld` + `WetWorldPipeline` +
`wet_world.vsh/fsh`) оказался технически лучшим из трёх: настоящий SSR с бинарным
рефайнментом пересечения, dithering шага луча, реконструкция нормали по соседям в буфере
глубины (не screen-space derivative), fbm-небо с sun specular, fog-интеграция.
Проверка нашего `wet_surface.frag` («может кривые») подтвердила баги:
1) `normalFromDepth` считался через `dFdx/dFdy(viewPos)` — рвётся на границах глубины,
шумная/блочная нормаль -> дрожащий край лужи;
2) рябь была МЁРТВЫМ КОДОМ: `vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;` —
умножение на 0 полностью гасило эффект, а сама рябь добавлялась как сырое значение
волны вместо её наклона.
Исправлено (коммит 0986ce6): нормаль — по 4 соседям depth-буфера (как в лучшем референсе),
рябь — аналитический градиент (разность высоты в соседних точках), добавлен bin-рефайнмент
SSR-пересечения + dithering, небо дополнено fbm-облаками/sun specular, затемнение вынесено
в отдельный uniform-параметр (было зашито константой 0.82). Новый модуль
`visuals/weather/WetWorld.java`: мочит мир по факту `mc.level.getRainLevel()`, независимо
от партиклов Rain и тумблера `wet_ground` — Quality (Low/Medium/High/Ultra
-> шаги/дистанция/bin-рефайнмент SSR), Reflection Strength, Darkening, Ripples+Speed,
Rain Only. Переиспользует общий `WET_SURFACE` pipeline/`WetSurfaceUniforms` (при
одновременном включении с Rain.wet_ground эффект стекается — задокументировано в коде).
Fog-интеграция референса НЕ перенесена (нет доступа к чужому Fog-модулю/данным тумана в
нашем движке в этом заходе) — можно добавить отдельно, если понадобится.
- [x] **HitEffect — интегрирован ImpactRing (2026-09-15).**
Референс `/tmp/lv_refs/myasomaybe/ImpactRing/` — `JumpWaveRenderer`/`JumpGlowRenderer`
используют захват сцены + собственный низкоуровневый RHI-пайплайн с SSR-варпом
(кастомный шейдер сэмплит скопированный цветовой буфер по кольцу) — тяжеловесная техника
не в духе нашего движка. Взята идея визуала (кольцо + вертикальная стена свечения в точке
удара), реализована через уже существующий движок: обычные аддитивные квады через
MeshBuilder/Renderer3D (`WORLD_COLORED_ADDITIVE` + `WORLD_COLORED_ADDITIVE_DEPTH`), без
захвата сцены и отдельного шейдера — консистентно с уже существующими WaveEffect/HitBubble
в том же файле. Новый `EffectMode.IMPACT` в HitEffect: настройки impact_radius/
impact_duration/impact_glow_height, срабатывает как WAVE (по удару), рендер —
расширяющееся кольцо (сегменты, fade к центру) + полупрозрачная вертикальная стена по
контуру круга, затухающая вверх и во времени. Коммит 61c478e. `./gradlew build`+`test` ✓.
- [x] **HoldMyItems — сверка с HMI.zip (2026-09-15), портирован grip.**
Референс `/tmp/lv_refs/hmi/` (Delta HMI): обёртка обфусцированного чит-клиента над
СТОРОННИМ jar-модом `holdmyitems-5.1.1+26.2.jar` + свои `SwingAnimations` (десяток стилей
свинга, weapon-inspect a-la CS:GO karambit-spin, "Pretty Hands" режимы). Полный набор
НЕ портирован — фича-крип чит-клиента, конфликтует с KISS и с правилом «без стороннего
jar в сборке» (Delta буквально бандлит чужой мод и синхронизирует с ним настройки через
`OriginalHmiBridge`). Взята единственная самоценная и самодостаточная идея — **grip**:
лёгкий хват предметов с рукоятью (мечи/инструменты/трезубец/палки — см.
`heldAlongTheFist`) наискось в кулаке вместо строго вертикального ванильного equip.
Портировано (коммит b257fde): `HeldAlongFist` (чистый предикат по ItemTags),
`HandPoseMath.grip(...)` (чистая функция позы, тесты gripMirrorsBySide/
gripExtendPullsCloserAndAcrossFist), настройки `grip_enabled`+6 slider'ов в HoldMyItems
(дефолты как у референсного пресета "HMI"), проброс ItemStack через
HandDynamics.apply/ItemInHandRendererMixin. `./gradlew build`+`test` зелёные.
### Проверка после каждого пункта: `./gradlew build` ✓, `./gradlew test` ✓, TODO.md отмечен
## Фаза 11: Аудит attack-визуалов — sacura 1.21.4 / GodWeer-26.2 (2026-09-22)
> Референсы распакованы в `ref/clients/sacura-1.21.4` и `ref/clients/godweer-26.2`.
> Вывод по большинству эффектов: у нас уже полноценнее (больше режимов, единый render
> engine с пайплайнами вместо сырого GL). Ниже — конкретные пробелы, которые реально
> стоит доделать; всё остальное сравнённое (HitEffect, KillEffect, JumpCircles,
> Hitboxes, ItemPhysic, ViewModel offsets) уже на уровне или выше обоих референсов.
- [ ] **ViewModel — `Custom` swing-режим.** sacura (`SwingAnimationsModule`) имеет режим
"Кастомная" с explicit слайдерами угол/наклон. У нас 8 фиксированных пресетов
(Hammer/Chop/Arc/Double/Shake/Shove/Slash/Thrust), но нет свободно настраиваемого.
Также посмотреть на sacura-режимы "Возвращение"/"Возвращение к себе"/"Круговая" —
возможно стоят портирования как доп. пресеты, если визуально отличаются от наших.
- [ ] **HitEffect — опциональный distortion/glow post-process слой.** godweer
(`HitEffectRenderer`) кроме частиц кладёт screen-space distortion+glow слайдеры.
У нас частицы (Bubbles/Wave/Impact) есть, post-process слоя над ними нет.
Паттерн `PostProcessPass` уже есть (см. `TotemFX`), можно переиспользовать —
но не блокирующая, это чистый апгрейд поверх уже более богатой системы.
- [ ] **JumpCircles — easing на появление/исчезновение.** sacura использует `EaseBackIn`
для пружинки при спавне/затухании круга. Проверить `render/engine/math` — если
easing-утилита уже есть, подключить к текущему fade вместо линейного.
- [ ] **KillEffect — through-walls поведение.** godweer (`PopEffect`) имеет явный toggle
"сквозь стены" для beam/взрыв эффектов. Проверить DepthMode у Lightning/Orthodox
beam в нашем KillEffect — сейчас режется окклюзией или нет; если режется и это не
специально — добавить toggle по аналогии.
- [ ] **Trails — живое сравнение с sacura `TrailsModule`.** Не разбирался по коду в этом
заходе (визуальный эффект, решение лучше принимать посмотрев обе стороны вживую
в игре, а не по исходникам).
### Итог: ничего из sacura/GodWeer не даёт принципиально нового attack-эффекта,
### которого нет в LoVisual — расхождение только в деталях полировки (easing-кривые,
### доп. custom-режим свинга, возможный distortion-слой, through-walls toggle).
---
## Фаза 12: Сессия 2026-10-01 — новые визуалы, меню, войсчат, автокомплит, починка запуска
> Всё за 2026-10-01 лежит ОДНИМ коммитом (владелец просит большие коммиты, не дробить).
> Игру в этой сессии запускал только владелец (`./gradlew runClient`, из каталога `mod/`);
> всё, что ниже помечено «не проверено вживую», видно только в игре.
### Сделано (код + сборка + тесты зелёные)
- [x] **TargetESP: режим «Созвездие» (`constellation`).** Звёзды на наклонной орбите вокруг цели,
линии между ними, свечение. Настройки: звёзды 3-24, скорость, орбита, линии, размер, свечение.
Геометрия: `combat/targetesp/constellation/` (чистый `ConstellationLayout` + тест).
`TargetESP.java` сокращён 329 -> 161 строка: отслеживание цели и плавное появление вынесены в
`targetesp/state/TargetEspTracker`, цвет в `TargetEspColors`.
- [x] **JumpCircles переписан** (саб-агент): 7 новых форм (Звезда, Ромб, Спираль, Гексагон, Волна, Крест,
Лепестки) к Круг/Кольцо/Пузырь; общая анимация (`CircleAnimation`), цвета из `AnimatedRenderColors`,
круг рождается на высоте земли в момент отрыва. Пакет `visuals/player/jumpcircles/` (+ `shape/`),
12 тестов. Удалены настройки `ease_out`, `pulse_max_size`, `pulse_lifetime`.
- [x] **KillEffect: режим «Кровь» (`Blood`)** по архиву killeffect.zip. Капли с физикой, отскоки от стен,
лужи на полу, мелкие брызги; через наш `Renderer3D`. Пакет `hits/blood/`. Настройки: цвет,
количество, сила, размер, время пятен, тип разлёта (Burst/Fountain/Ring/Spray; Spray летит от атакующего).
- [x] **Главное меню: тип «Hero»** по архиву Ya_Lublu_Femboyev.zip (экран Vectra). Настройка `menuLayout`
(`orbs` по умолчанию / `hero`), фабрика `MainMenus`, экран `mainmenu/hero/*`, чистая логика
`hero/logic/*` + тесты. Интеграция: альт-менеджер, `SkinManager`, наши темы.
- [x] **Simple Voice Chat (мягкая совместимость, API 2.6.24).** `compat/voicechat/` (плагин, `VoiceState`,
`VoiceChatBridge`), модуль `VoiceChat` (кольца и свечение над говорящими, крест при заглушённом
микрофоне), HUD-панель `VoiceChat` (список говорящих + статус микрофона). API не попадает в jar.
- [x] **`render/engine/renderer/quick/Draw`** — короткий статический фасад над `Renderer2D.COLOR`
(rect, gradient, outline, glassOutline, shadow, circle) + `GlassEdge` с тестом.
Заметка: у `Renderer2D` короткие перегрузки `roundedRect(x,y,w,h,radius,argb)` уже были,
6-й параметр в длинной форме это `softness`, а не alpha.
- [x] **Автокомплит `%` в ванильном чате** (как `#` у Baritone): `mixins/gui/chat/CommandSuggestionsMixin`
подставляет наши команды/аргументы в попап по мере набора; подсказки аргументов `<module>`
показываются серым текстом (`command/suggest/SuggestionPlan` + тест). BetterChat идёт своим путём.
Раньше подсказки работали только в BetterChat.
- [x] **Починка запуска клиента** (игра не стартовала с августа, все пять дефектов найдены по логам runClient):
1. MixinExtras 0.5.4 падал на `@Redirect(at=...)` (ClassCastException при трансформации `Entity`):
добавлен `mixinextras-fabric:0.5.5` (`gradle.properties: mixinextras_version`), вшит в jar через `include`.
2. `ServerPolicyClient`: `registerReceiver` -> `registerGlobalReceiver`.
3. Классы-помощники лежали в пакете миксинов (Mixin запрещает их загрузку): вынесены в
`render/gamerenderer/` и `mixinhandlers/{player,hud,network}/`, сделаны public.
4. `Themes`: `STORE` создавался раньше таблицы пресетов (NPE в `ThemeStoreIO`): порядок инициализации.
5. `MainSettingsComponent.rebuildSettings`: `addAll` в неизменяемый список (падал ClickGUI по RShift).
- [x] 2026-10-01 все сегодняшние коммиты объединены в один по просьбе владельца.
### Отзывы владельца после запуска (скриншоты) — СДЕЛАНО в коде, ждёт проверки в игре
Итог 2026-10-01 (сборка и тесты зелёные, визуально не проверено):
- Тултипы: причина была в том, что пользовательский тултип, перекраска, фон и предпросмотр шалкера читали
собственные флаги и игнорировали переключатель модуля; теперь везде `isEnabled()`. Синие строки
категорий красятся акцентом темы (`TooltipColors`). Тема по умолчанию `Themes.THEME_DEFAULT = AMBER`
(сохранённый выбор пользователя не трогается).
- Описания: ~98 новых ru/en (62 модуля, 36 HUD), `HudElement.getDescription()`, тест `DescriptionCoverageTest`.
- ClickGUI: плашка с описанием над колонками (`ModulesMenuDescriptionRenderer`, `DescriptionLines`).
- Hero-меню: раскладка по измеренному тексту (`TextMetrics`, `HeroLayout`), кнопки с SVG-иконками
(`hero/paint/*`), orbs: подпись под кнопкой при наведении (`MainMenuHoverLabel`).
- Настройки меню: новая категория «Главное меню» (`MAIN_MENU`), `MainConfig.getMainMenuSettingDefs()`.
- Проверить в игре: положение и размер плашки описания, вид amber-тултипа, иконки и шрифты Hero-меню,
подпись orbs-кнопок, видимость настроек (`visibleWhen`) в категории «Главное меню».
Задача А (форк, тултипы и описания):
- [x] **Тултип предметов (BetterTooltips) странный и не выключается.** При первом запуске у камня синие
строки-категории («Building Blocks / Natural Blocks») и тёмная панель; даже при выключенном модуле
стиль остаётся. Нужно: найти причину (миксин `mixins/hud/tooltips/TooltipRenderUtilMixin`
применяет стиль без проверки включённости / кэш `TooltipState`), при выключении тултип строго
ванильный, при включении аккуратный стиль. **Стиль по умолчанию должен быть `amber`, а не `classic`**
(`Themes.THEME_AMBER`); старые конфиги не ломать.
- [x] **Нет описаний модулей и HUD-элементов в настройках.** На всех карточках HUD/UI написано
«Module description is missing.». Нужно добавить описания ru/en для каждого `@ModuleInfo`-модуля и
каждого HUD-элемента (ключи `module.<id>.description` и HUD-аналог), проверить, что не осталось
пустых (тест или скрипт сверки id <-> lang).
- [x] **Описание функции при наведении в обычном ClickGUI** (колонки Combat/Player/Visuals/Other).
В области сверху по центру над колонками показывать описание функции под курсором (плашка в нашем
стиле, плавное появление, перенос/обрезка по ширине).
Задача Б (форк, главное меню):
- [x] **Hero-меню «калово»**: заголовок налезает на подзаголовок и кнопки, нет иконок на кнопках,
мелкие и кривые шрифты. Эталон: референс Vectra (иконки слева в кнопках, акцентная Multiplayer,
тёмное стекло у остальных, пара Options/Quit внизу, карточка аккаунта слева сверху, виджет
времени справа сверху). Раскладку считать по измеренным размерам текста, а не по константам.
- [x] **Основное (orbs) меню тоже улучшить стилем** (типографика, тени, hover, появление).
- [x] **Настройки главного меню разбросаны и спрятаны** (`menuLayout`, `menuBg`, `menuBgUseTheme`,
`menuClockShowSeconds` в `MainConfig`). Сгруппировать в понятный раздел «Главное меню» в
настройках (ключи конфигов не менять, чтобы сохранённые значения не потерялись).
### Правки после второго запуска (2026-10-01)
- [x] `%tier <player>`: не было `suggest` (подсказка показывала только серый `<player>`): теперь ники игроков и режимы
(`vanilla|uhc|pot|nethop|smp|sword|axe|mace`). Остальные команды без `suggest` не имеют аргументов.
- [x] KillEffect Blood было видно сквозь блоки: пайплайн `WORLD_COLORED` без depth-теста заменён на `WORLD_COLORED_DEPTH`.
- [x] TargetESP: новый режим `swords` (8 мечей на орбите, пружинная физика, отдача при ударе, подсветка при хите).
`targetesp/sword/` (`SwordPhysics` чистая + 4 теста, `TargetEspSwords` рендер), текстура `particles/sword.png`
нарисована скриптом (пиксель-арт, окрашивается цветом палитры). Мечи рисуются поверх блоков (ESP, без depth).
Не проверено вживую: вид, размер, яркость свечения, текстура меча (при желании заменить на свою).
### Фаза 13: разбор evo клиента (2026-10-01), план работ
Отчёт: `mod/docs/EVO_REPORT.md` (29 модулей evo, которых нет у нас; крылья; ножи; HMI/Lua).
Лицензия: `ref/evo/README.md` объявляет код и ассеты проприетарными. Для релиза только свои модели/скрипты
или разрешение автора; формат (Bedrock geo/anim, HMI-Lua) стандартный, код пишем свой.
Порядок по просьбе владельца: сначала 1, потом остальное.
- [x] 1. HoldMyItems Scripted на LuaJ (пункты 1-8 отчёта, частично): настройка `Режим` = Dynamics / Scripts / Both
(по умолчанию Dynamics, LuaJ грузится лениво). `held/lua/`: `HandScripts` (загрузка + горячая перезагрузка раз
в секунду из `config/lovisual/holdmyitems/{hand_pose,item_pose}.lua`, иначе встроенные примеры в
`assets/lovisual/holdmyitems/`), `HandFrame`; `runtime/`: `LuaSandbox` (только base/bit32/table/string/math,
без io/os/luajava/require/load/debug, лимит инструкций через хук), `LuaPreprocessor` (`global.x = v;` -> registry),
`ScriptSlot` (ошибка выключает только этот скрипт, лог один раз); `api/`: `M` (move/rotate с pivot/scale/push/pop/
math), `P`, `I`, `Easings` (8 функций), без отдачи Java-объектов в Lua. Хуки в `ItemInHandRendererMixin`:
hand_pose в `submitArmWithItem`, item_pose в `renderItem` (first person). 7 тестов `LuaSandboxTest`.
(Хвост из 1-8 закрыт в пункте 1b ниже.)
- [x] 1b. HMI хвост (сделано): 4 вида скриптов `hand_pose`, `hand_relative_pose`, `item_pose`, `item_model` + аддоны
`hand_addon`, `hand_relative_addon`, `item_addon`, `item_model_addon` (только из `config/lovisual/holdmyitems/`,
выполняются сразу после основного). `V:cancelEquip/cancelSwing(bool)` отключают ваниль (флаги читаются на следующем
кадре); настройка «Всегда рисовать обе руки» (`always_hands`); `animator` (`ext/ModelAnimator` + миксин
`ItemModelAnimatorMixin` подменяет список квадов слоя, операции rotate/move/scale по диапазону индексов, pivot
в пикселях); `particleManager:addParticle{...}` с Lua-колбэком `tick` (`ext/HandParticles`, аддитивный BLOOM в
мире); `C:setCamRot/setCamPos` (`CameraScriptMixin`, действует 150 мс после вызова). Встроенные примеры для всех
скриптов. Тесты: `QuadOpsTest`. Не сделано: события `swingMHand/interact/blockBreaking`, `use_duration`/
`using_item` для моделей, ItemStack-поля (`setRenderAsBlock`), крен камеры (roll), загрузка из ресурс-паков.
Не проверено вживую: оси и знаки M:rotate, видимость Lua-частиц, анимация квадов на реальной модели.
- [ ] 2. Wings: режим «Модель» из evo (подробности: `docs/EVO_REPORT.md`, раздел 2, шаги в 2.4).
Формат Bedrock `geo.json` + `animation.json`; 14 видов: angel, blue_dragon, dark, demon, dragon, easter, ghoul,
pulse, rocker, spider, trident, vulcano, wota (+ baby_dragon). angel/demon/dragon анимируются процедурно в коде
evo (`Internal29/41/42`), остальные из animation.json; wota с анимированной текстурой (64x640, 10 кадров по 150 мс).
Источник (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/*.{geo,animation}.json`,
`.../textures/cosmetic/wings/*.png`. Целевые пути: `assets/lovisual/wings/`, `assets/lovisual/textures/wings/`.
- [ ] `BedrockGeoParser` (общий с ножами) + запекание кубов в позиции/UV
- [ ] `BedrockAnimation` (keyframe, loop) и `ProceduralWingAnims` (angel/demon/dragon)
- [ ] `WingsRenderer.renderModel` (WORLD_TEXTURED[_DEPTH], якорь как у `drawWingSide`, подобрать знак Z и ориентацию)
- [ ] настройки в `Wings`: `wingSource` (Procedural/Model) + `wingModel` (14 ключей), tint/glow, lang en/ru
- [ ] тесты парсера, знаков pivot/rotation, UV, интерполяции
- [ ] 3. Модуль Knife (Melee Skins), раздел 3 отчёта, таблица классов в 3.7. 33 скина (21 геометрия, 12 перекрасок),
клипы draw/draw_1/inspect/inspect_1/melee_1..3/static_idle/run_start/run/run_end; ЛКМ чередует melee_1/2,
ПКМ melee_3, бег при спринте; first person и third person; звуки (94 ogg); шейдер holy.
Источник: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/` (index.json, *.geo.json, *.anim.json), `textures/cosmetic/melee/`,
`sounds/melee/`, `shaders/core/melee_holy.*`. Целевые: `assets/lovisual/knife/`, `textures/knife/`, `sounds/knife/`.
Порядок: (1) общий BedrockGeoParser, (2) клипы + `KnifeAnimationState`, (3) first person без рук, (4) руки игрока,
(5) third person (миксин `PlayerItemInHandLayer`), (6) звуки, (7) бег/варианты/holy, (8) UI выбора скина и сохранение.
Миксины: ввод (атака/ПКМ), `ItemInHandRenderer`, `PlayerItemInHandLayer`.
- [ ] 4. Быстрые модули (P1, размер S): TNTTimer, AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint,
PlaceAnimation, GodRays (референс-шейдеры `shaders/core/godrays`), ProjectileTrails (на нашем `Trails`), ArmorColor.
- [ ] 5. Дальше (P1-P2): DeathEffects (распад на части, шейдер `death_blast`), Blizzard, ElytraTrails, TotemSound, Sounds,
InventorySort, StorageTracker, ConsumeESP, CustomHand, DynamicLights (проверить `Light`), FoodInfo, PatPat, Showcase.
P3: Chunk Cache, Schematics, HitFix (риск античита), Camera, Graffiti, Skin Wheel, Discord RPC, Replay, Trainer.
Пропустить: GuiPresence (нужен бэкенд evo). Полный список из 29 модулей с оценкой: отчёт, раздел 1.2.
- [ ] 6. Лицензия: `ref/evo/README.md` объявляет код и ассеты evo проприетарными. Перед релизом заменить на свои модели/
звуки или получить разрешение автора; до тех пор пути из отчёта только для локальной отладки, в репозиторий
ассеты evo не коммитить.
### Не проверено вживую / открытые вопросы
- [ ] Проверить в игре автокомплит `%`: ввод `%`, `%to`, Tab; серая подсказка `<module>` после `%toggle `.
Если миксин не применится, прислать лог (поля `CommandSuggestions` сверены по байткоду 26.2).
- [ ] Визуально подогнать: режим «Созвездие», новые формы JumpCircles (ориентация текстур, яркость
аддитивного бленда), кровь (лужи, яркость), Hero/orbs-меню, индикаторы войсчата.
- [ ] Войсчат: события SVC трактованы по javap (`MicrophoneMuteEvent.isDisabled()` как «заглушён или
выключен», `getEntityId()` как UUID игрока); проверить с настоящим SVC 2.6.24; значок шапки HUD-панели
сейчас wifi-глиф (подходящего микрофона в шрифте нет).
- [ ] Тип ClickGUI (идея владельца): селектор «Тип ClickGUI» в настройках + точка выбора экрана
(`ClickGuiScreenManager`, три места `new ClickGuiScreen()`), `classic` по умолчанию, второй вид позже;
бинд и звуки остаются в модуле `ClickGui`. Ждёт решения, какой второй вид делать первым.
- [ ] `.gitignore` для `server-plugin/.classpath`, `.project`, `.settings/`.
- [ ] Фаза 8.5.3 по-прежнему в очереди (около 150 файлов >250 строк), по мере касания.
- [ ] Приёмка серверного плагина (Фаза 11 в корневом TODO.md): Velocity + `%policy` на стенде.
### Заметки для следующих сессий
- Игру запускает владелец: `cd mod && ./gradlew runClient`. Из корня репозитория `./gradlew` не существует.
- Параллельные gradle-сборки блокируют друг друга: форкам запускать через
`flock /tmp/claude-1000/gradle.lock ./gradlew build --offline -q`.
- Новые классы-помощники для миксинов НЕЛЬЗЯ класть в пакет `dev.loki.lovisual.mixins.*`
(Mixin: `IllegalClassLoadError`); им место в `mixinhandlers/` или `render/`.
- Лимиты проекта: ≤250 строк на файл, ≤4 файла на папку (`checkFolderLimit` падает при нарушении),
без длинного тире в текстах сайта и ответах.

View file

@ -0,0 +1,53 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
import dev.loki.lovisual.features.gui.clickgui.tabs.ClickGuiSearch;
/** Builds the {@link MenuPresence} snapshot of the modules menu for the given mouse position. */
final class ModulesMenuPresence {
private static final int NO_MODULE = 255;
private ModulesMenuPresence() {
}
static MenuPresence snapshot(ModulesMenuScreen screen, float mx, float my, boolean pressed) {
float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE, maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE;
for (ModulesMenuPanel p : screen.panels) {
minX = Math.min(minX, p.x);
minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x + p.w);
maxY = Math.max(maxY, p.y + p.h);
}
if (maxX <= minX || maxY <= minY) return null;
int category = -1;
int module = NO_MODULE;
boolean settings = false;
int scroll = 0;
for (ModulesMenuPanel p : screen.panels) {
if (!ModulesMenuScreen.inside(mx, my, p.x, p.y, p.w, p.h)) continue;
category = p.index;
settings = p.selected != null;
if (settings) {
scroll = ratio(p.settingsSmoothScroll, p.maxSettingsScroll);
break;
}
scroll = ratio(p.modulesSmoothScroll, p.maxModulesScroll);
for (int i = 0; i < p.hits.size() && i < NO_MODULE; i++) {
ModulesMenuPanel.ModuleHit h = p.hits.get(i);
if (ModulesMenuScreen.inside(mx, my, h.x(), h.y(), h.w(), h.h())) {
module = i;
break;
}
}
break;
}
float x = Math.max(0.0f, Math.min(1.0f, (mx - minX) / (maxX - minX)));
float y = Math.max(0.0f, Math.min(1.0f, (my - minY) / (maxY - minY)));
return new MenuPresence(category, module, settings, ClickGuiSearch.isActive(), x, y, pressed, scroll);
}
private static int ratio(float value, float max) {
return max <= 0.0f ? 0 : Math.max(0, Math.min(255, Math.round(Math.abs(value) / max * 255.0f)));
}
}

View file

@ -1,6 +1,7 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules;
import dev.loki.lovisual.config.i18n.MainConfig;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuCategory;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuStyle;
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
@ -32,6 +33,8 @@ public final class ModulesMenuScreen {
float searchAnim = 0.0f; float searchX; float searchY; float searchW; float searchH;
float searchBtnX; float searchBtnY; float searchBtnW; float searchBtnH; float searchBtnHoverAnim;
float descAnim; String descTitle; String descText;
private static ModulesMenuScreen active;
private float lastMouseX; private float lastMouseY;
public ModulesMenuScreen() {
for (ModulesMenuCategory c : ModulesMenuCategory.values()) panels.add(new ModulesMenuPanel(c, c.ordinal()));
@ -48,12 +51,20 @@ public final class ModulesMenuScreen {
}
public boolean isVisible() { return openTarget || screenAnim > 0.001f; }
/** State of the currently shown modules menu for GUI presence, or null when no menu is open. */
public static MenuPresence presenceSnapshot(boolean mouseDown) {
ModulesMenuScreen s = active;
if (s == null || !s.openTarget || s.screenAnim < 0.6f) return null;
return ModulesMenuPresence.snapshot(s, s.lastMouseX, s.lastMouseY, mouseDown);
}
public void layout(float x, float y, float w, float h) {
this.areaX = x; this.areaY = y; this.areaW = Math.max(1.0f, w); this.areaH = Math.max(1.0f, h);
ModulesMenuLayout.layout(this, panels, areaX, areaY, areaW, areaH);
}
public void render(float mouseX, float mouseY) {
lastMouseX = mouseX; lastMouseY = mouseY; active = this;
ModulesMenuChromeRenderer.ensureFonts();
ModulesMenuStyle.syncTheme();
float dt = AnimationUtility.deltaTime();

View file

@ -0,0 +1,10 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model;
/**
* Snapshot of the local ClickGUI for GUI presence: hovered column, module row under the cursor (255 = none),
* whether that column shows a module's settings, search state, cursor position normalised to the panels' bounds
* (0..1), left button and list scroll (0..255).
*/
public record MenuPresence(int category, int moduleIndex, boolean settingsOpen, boolean searching,
float x, float y, boolean pressed, int scroll) {
}

View file

@ -0,0 +1,92 @@
package dev.loki.lovisual.features.module.modules.visuals.presence;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.platform.presence.PresenceClient;
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ServerData;
import net.minecraft.world.entity.player.Player;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Shows other LoVisual users' open ClickGUI as a small mirror held in front of them, and shares your own cursor the
* same way. Needs a linked LoVisual account (device token) and a multiplayer server; data goes through the
* chat-service presence endpoint and contains only the cursor position and a few menu flags.
*/
@ModuleInfo(
id = "guipresence",
displayName = "GuiPresence",
category = ModuleCategory.VISUALS,
description = "Shows the ClickGUI other LoVisual users have open, and shares yours"
)
public class GuiPresence extends Module {
private static final float FADE_RANGE = 6.0f;
private static final int MAX_WANT = 40;
private final PresenceClient client = new PresenceClient();
private final PresenceSampler sampler = new PresenceSampler();
private final PresencePanelRenderer panel = new PresencePanelRenderer();
private final BooleanValue share = bool("guiPresenceShare", "share", true);
private final BooleanValue showOthers = bool("guiPresenceShowOthers", "show_others", true);
private final NumberValue<Integer> distance = visibleWhen(num("guiPresenceDistance", "distance", 32, 8, 64), showOthers::get);
private final NumberValue<Float> scale = visibleWhen(num("guiPresenceScale", "scale", 1.0f, 0.5f, 2.0f), showOthers::get);
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onDisable() {
client.clear();
}
@Override
public void onTick() {
Minecraft mc = Minecraft.getInstance();
ServerData server = mc.getCurrentServer();
if (!isEnabled() || mc.level == null || mc.player == null || server == null) return;
if (!share.get() && !showOthers.get()) return;
long now = System.currentTimeMillis();
boolean open = share.get() && sampler.sample(now);
if (!client.due(now, open)) return;
client.sync(now, server.ip, mc.player.getUUID(), open ? sampler.drain() : null, share.get(), wanted(mc));
}
private List<UUID> wanted(Minecraft mc) {
List<UUID> want = new ArrayList<>();
if (!showOthers.get()) return want;
double max = distance.get() + FADE_RANGE;
for (Player p : mc.level.players()) {
if (p == mc.player || mc.player.distanceTo(p) > max) continue;
want.add(p.getUUID());
if (want.size() >= MAX_WANT) break;
}
return want;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || !showOthers.get() || mc.level == null || mc.player == null) return;
long now = System.currentTimeMillis();
for (Map.Entry<UUID, PresenceTimeline> e : client.remote().entrySet()) {
Player p = mc.level.getPlayerByUUID(e.getKey());
if (p == null || p == mc.player || !e.getValue().live(now)) continue;
float d = mc.player.distanceTo(p);
float fade = Math.min(1.0f, (distance.get() - d) / FADE_RANGE + 1.0f);
panel.draw(renderer, p, tickDelta, e.getValue(), now, scale.get(), Math.max(0.0f, fade));
}
}
}

View file

@ -0,0 +1,128 @@
package dev.loki.lovisual.features.module.modules.visuals.presence;
import dev.loki.lovisual.features.platform.presence.PresenceCodec;
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
/**
* Draws a remote player's ClickGUI as a small flat mirror held in front of them: four columns, the one under their
* cursor lit, the row under the cursor highlighted, the cursor itself and a ring while the button is pressed.
* Built from coloured quads in a plane spanned by the player's facing, so it needs no text or textures.
*/
final class PresencePanelRenderer {
private static final float ASPECT = 65.0f / 104.0f;
private static final float FORWARD = 0.85f;
private static final float HEIGHT_RATIO = 0.6f;
private static final int COLUMNS = 4;
private static final int ROWS = 7;
private static final float GAP = 0.018f;
private static final float LAYER = 0.0025f;
private final float[] a = new float[3];
private final float[] up = {0.0f, 1.0f, 0.0f};
private final float[] f = new float[3];
private final float[] c = new float[3];
private float width;
private float height;
void draw(Renderer3D renderer, Player player, float tickDelta, PresenceTimeline timeline, long now,
float scale, float alpha) {
PresenceCodec.Gui gui = timeline.meta();
PresenceTimeline.Point cursor = timeline.at(now);
if (gui == null || gui.closed() || cursor == null || alpha <= 0.01f) return;
MeshBuilder m = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
if (m == null) return;
place(player, tickDelta, scale);
int accent = Theme.theme().accent() & 0x00FFFFFF;
int bg = Theme.theme().windowBg() & 0x00FFFFFF;
quad(m, 0.0f, 0.0f, 1.0f, 1.0f, 0, rgba(bg, 0.82f * alpha));
float colW = (1.0f - GAP * (COLUMNS + 1)) / COLUMNS;
float cx = cursor.x() / 255.0f;
float cy = cursor.y() / 255.0f;
for (int i = 0; i < COLUMNS; i++) {
float x0 = GAP + i * (colW + GAP);
boolean active = i == gui.category();
quad(m, x0, 0.04f, x0 + colW, 0.96f, 1, rgba(active ? accent : bg, (active ? 0.28f : 0.5f) * alpha));
quad(m, x0, 0.04f, x0 + colW, 0.13f, 2, rgba(accent, (active ? 0.7f : 0.3f) * alpha));
rows(m, i, x0, colW, active, gui, cy, accent, alpha);
}
if (gui.searching()) quad(m, 0.3f, -0.1f, 0.7f, -0.03f, 2, rgba(accent, 0.85f * alpha));
cursor(m, cx, cy, cursor.pressed(), accent, alpha);
}
private void rows(MeshBuilder m, int column, float x0, float colW, boolean active, PresenceCodec.Gui gui,
float cy, int accent, float alpha) {
float top = 0.17f;
float step = (0.95f - top) / ROWS;
int hot = active && !gui.settings() ? (int) Math.floor((cy - top) / step) : -1;
for (int r = 0; r < ROWS; r++) {
float y0 = top + r * step;
float w = active && gui.settings() ? colW * (0.35f + 0.55f * (((r * 37 + column * 11) % 10) / 10.0f)) : colW * 0.82f;
boolean lit = r == hot && gui.moduleIndex() != PresenceCodec.NO_MODULE;
quad(m, x0 + colW * 0.09f, y0, x0 + colW * 0.09f + w, y0 + step * 0.7f, 2,
rgba(lit ? accent : 0xFFFFFF, (lit ? 0.75f : 0.1f) * alpha));
}
}
private void cursor(MeshBuilder m, float cx, float cy, boolean pressed, int accent, float alpha) {
float s = 0.014f;
quad(m, cx - s, cy - s, cx + s, cy + s, 4, rgba(0xFFFFFF, alpha));
if (!pressed) return;
float r = 0.035f;
float t = 0.006f;
int col = rgba(accent, 0.9f * alpha);
quad(m, cx - r, cy - r, cx + r, cy - r + t, 3, col);
quad(m, cx - r, cy + r - t, cx + r, cy + r, 3, col);
quad(m, cx - r, cy - r, cx - r + t, cy + r, 3, col);
quad(m, cx + r - t, cy - r, cx + r, cy + r, 3, col);
}
private void place(Player p, float tickDelta, float scale) {
double yaw = Math.toRadians(p.yBodyRot);
a[0] = (float) Math.cos(yaw);
a[1] = 0.0f;
a[2] = (float) Math.sin(yaw);
f[0] = (float) -Math.sin(yaw);
f[1] = 0.0f;
f[2] = (float) Math.cos(yaw);
Vec3 pos = p.getPosition(tickDelta);
float h = p.getBbHeight() * HEIGHT_RATIO;
c[0] = (float) pos.x + f[0] * FORWARD;
c[1] = (float) pos.y + h;
c[2] = (float) pos.z + f[2] * FORWARD;
width = scale;
height = scale * ASPECT;
}
/** Quad in panel space: u to the right, v downward, both 0..1; {@code layer} lifts it toward the viewer. */
private void quad(MeshBuilder m, float u0, float v0, float u1, float v1, int layer, int argb) {
m.ensureQuadCapacity();
RenderColor col = new RenderColor(argb);
int i1 = vertex(m, u0, v0, layer, col);
int i2 = vertex(m, u1, v0, layer, col);
int i3 = vertex(m, u1, v1, layer, col);
int i4 = vertex(m, u0, v1, layer, col);
m.quad(i1, i2, i3, i4);
}
private int vertex(MeshBuilder m, float u, float v, int layer, RenderColor col) {
float du = (u - 0.5f) * width;
float dv = (0.5f - v) * height;
float off = layer * LAYER;
return m.vec3(c[0] + a[0] * du + up[0] * dv + f[0] * off,
c[1] + a[1] * du + up[1] * dv + f[1] * off,
c[2] + a[2] * du + up[2] * dv + f[2] * off).color(col).next();
}
private static int rgba(int rgb, float alpha) {
int al = Math.max(0, Math.min(255, Math.round(alpha * 255.0f)));
return (al << 24) | (rgb & 0x00FFFFFF);
}
}

View file

@ -0,0 +1,49 @@
package dev.loki.lovisual.features.module.modules.visuals.presence;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.ModulesMenuScreen;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
import dev.loki.lovisual.features.platform.presence.PresenceCodec;
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
import net.minecraft.client.Minecraft;
import org.lwjgl.glfw.GLFW;
import java.util.ArrayList;
import java.util.List;
/** Samples the local ClickGUI cursor every 100 ms into a short queue that is flushed with the next sync. */
final class PresenceSampler {
private final List<PresenceCodec.Sample> queue = new ArrayList<>();
private MenuPresence last;
private long lastSample;
/** @return true while a menu is open (and sampled) */
boolean sample(long now) {
Minecraft mc = Minecraft.getInstance();
boolean down = mc.getWindow() != null
&& GLFW.glfwGetMouseButton(mc.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
MenuPresence snap = ModulesMenuScreen.presenceSnapshot(down);
if (snap == null) {
last = null;
queue.clear();
return false;
}
last = snap;
if (now - lastSample >= PresenceTimeline.SAMPLE_MS) {
lastSample = now;
queue.add(new PresenceCodec.Sample(Math.round(snap.x() * 255.0f), Math.round(snap.y() * 255.0f),
snap.pressed(), snap.scroll()));
while (queue.size() > PresenceCodec.MAX_SAMPLES) queue.remove(0);
}
return true;
}
/** Payload for the current menu state with the queued samples, or null when no menu is open. */
PresenceCodec.Gui drain() {
if (last == null) return null;
PresenceCodec.Gui gui = new PresenceCodec.Gui(last.category(), last.settingsOpen(), last.searching(), false,
last.moduleIndex(), new ArrayList<>(queue));
queue.clear();
return gui;
}
}

View file

@ -0,0 +1,107 @@
package dev.loki.lovisual.features.platform.presence;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import dev.loki.lovisual.features.platform.PlatformSession;
import dev.loki.lovisual.features.platform.http.ApiResult;
import dev.loki.lovisual.features.platform.http.ClientThread;
import dev.loki.lovisual.features.platform.http.PlatformHttpClient;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Poll loop of GUI presence ({@code POST /presence/gui}): one request publishes the local menu state and returns the
* state of nearby players. Must be driven from the client thread; responses hop back onto it, so {@link #remote()}
* is only touched there. Adaptive cadence: fast while a menu (local or remote) is live, slow heartbeat otherwise.
*/
public final class PresenceClient {
private static final long FAST_MS = 350L;
private static final long IDLE_MS = 2000L;
private static final long ERROR_BACKOFF_MS = 8000L;
private static final long AUTH_BACKOFF_MS = 30000L;
private static final long FORGET_MS = 15000L;
private final Map<UUID, PresenceTimeline> remote = new HashMap<>();
private boolean inFlight;
private boolean anyLive;
private long nextAt;
public Map<UUID, PresenceTimeline> remote() {
return remote;
}
public void clear() {
remote.clear();
anyLive = false;
nextAt = 0L;
}
public boolean due(long now, boolean localOpen) {
return !inFlight && now >= nextAt && PlatformSession.get().isLinked();
}
/** Next send interval for the current state; exposed so the caller can decide how often to sample. */
public long interval(boolean localOpen) {
return localOpen || anyLive ? FAST_MS : IDLE_MS;
}
/**
* @param local local menu state to publish, or null for a "LoVisual is running" heartbeat (ignored when
* {@code publish} is false)
* @param publish false for a read-only poll
*/
public void sync(long now, String server, UUID self, PresenceCodec.Gui local, boolean publish, List<UUID> want) {
inFlight = true;
JsonObject body = new JsonObject();
body.addProperty("server", server);
body.addProperty("mc", self.toString());
body.addProperty("publish", publish);
if (publish && local != null) body.addProperty("gui", PresenceCodec.encode(local));
JsonArray wanted = new JsonArray();
want.forEach(id -> wanted.add(id.toString()));
body.add("want", wanted);
boolean localOpen = local != null && !local.closed();
PlatformHttpClient.post("/presence/gui", body, PlatformSession.get().getDeviceToken())
.thenAccept(result -> ClientThread.run(() -> onResult(result, localOpen)));
}
private void onResult(ApiResult result, boolean localOpen) {
inFlight = false;
long now = System.currentTimeMillis();
if (!result.success()) {
nextAt = now + (result.statusCode() == 401 || result.statusCode() == 409 ? AUTH_BACKOFF_MS : ERROR_BACKOFF_MS);
return;
}
JsonObject obj = result.asObject();
JsonElement players = obj == null ? null : obj.get("players");
if (players != null && players.isJsonArray()) {
for (JsonElement e : players.getAsJsonArray()) apply(e, now);
}
for (Iterator<PresenceTimeline> it = remote.values().iterator(); it.hasNext(); ) {
if (!it.next().present(now - FORGET_MS + PresenceTimeline.STALE_MS * 4)) it.remove();
}
anyLive = remote.values().stream().anyMatch(t -> t.live(now));
nextAt = now + interval(localOpen);
}
private void apply(JsonElement e, long now) {
if (!e.isJsonObject()) return;
JsonObject o = e.getAsJsonObject();
try {
UUID id = UUID.fromString(o.get("mc").getAsString());
long age = o.has("age") ? o.get("age").getAsLong() : 0L;
JsonElement g = o.get("gui");
PresenceCodec.Gui gui = g == null || g.isJsonNull() ? null : PresenceCodec.decode(g.getAsString());
if (gui == null) gui = new PresenceCodec.Gui(-1, false, false, true, PresenceCodec.NO_MODULE, List.of());
remote.computeIfAbsent(id, k -> new PresenceTimeline()).add(now - age, gui);
} catch (RuntimeException ignored) {
// malformed entry from the server: skip it, the rest of the response is still valid
}
}
}

View file

@ -0,0 +1,77 @@
package dev.loki.lovisual.features.platform.presence;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
/**
* Wire format of the GUI presence payload (see {@code backend/chat-service/PLAN.md}): 4 header bytes and up to
* 8 cursor samples of 4 bytes. Pure and symmetric, so it is unit-tested without Minecraft.
*/
public final class PresenceCodec {
public static final int VERSION = 1;
public static final int MAX_SAMPLES = 8;
public static final int NO_MODULE = 255;
private static final int HEADER = 4;
/** Cursor position inside the menu (0..255 on each axis), left button state and list scroll (0..255). */
public record Sample(int x, int y, boolean pressed, int scroll) {
}
/**
* @param category menu column 0..15 (-1 is stored as 15 by {@link #encode} and decoded back to -1)
* @param moduleIndex index of the module row under the cursor, {@link #NO_MODULE} when none
*/
public record Gui(int category, boolean settings, boolean searching, boolean closed, int moduleIndex,
List<Sample> samples) {
}
private PresenceCodec() {
}
public static String encode(Gui gui) {
int n = Math.min(MAX_SAMPLES, gui.samples().size());
byte[] out = new byte[HEADER + n * 4];
out[0] = VERSION;
int cat = gui.category() < 0 ? 15 : gui.category() & 15;
out[1] = (byte) (cat | (gui.settings() ? 16 : 0) | (gui.searching() ? 32 : 0) | (gui.closed() ? 128 : 0));
out[2] = (byte) gui.moduleIndex();
out[3] = (byte) n;
for (int i = 0; i < n; i++) {
Sample s = gui.samples().get(i);
out[HEADER + i * 4] = (byte) clamp(s.x());
out[HEADER + i * 4 + 1] = (byte) clamp(s.y());
out[HEADER + i * 4 + 2] = (byte) (s.pressed() ? 1 : 0);
out[HEADER + i * 4 + 3] = (byte) clamp(s.scroll());
}
return Base64.getEncoder().encodeToString(out);
}
/** Returns null for anything malformed or from another protocol version. */
public static Gui decode(String base64) {
if (base64 == null) return null;
byte[] in;
try {
in = Base64.getDecoder().decode(base64);
} catch (IllegalArgumentException e) {
return null;
}
if (in.length < HEADER || in[0] != VERSION) return null;
int n = in[3] & 0xFF;
if (n > MAX_SAMPLES || in.length < HEADER + n * 4) return null;
int flags = in[1] & 0xFF;
int cat = flags & 15;
List<Sample> samples = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
int o = HEADER + i * 4;
samples.add(new Sample(in[o] & 0xFF, in[o + 1] & 0xFF, (in[o + 2] & 1) != 0, in[o + 3] & 0xFF));
}
return new Gui(cat == 15 ? -1 : cat, (flags & 16) != 0, (flags & 32) != 0, (flags & 128) != 0,
in[2] & 0xFF, samples);
}
private static int clamp(int v) {
return Math.max(0, Math.min(255, v));
}
}

View file

@ -0,0 +1,87 @@
package dev.loki.lovisual.features.platform.presence;
import java.util.ArrayDeque;
/**
* Playback buffer of one remote player's cursor. Payloads arrive in batches (about 3 per second) carrying samples
* spaced {@link #SAMPLE_MS} apart; the renderer plays them back {@link #DELAY_MS} late and interpolates, so the
* remote cursor moves smoothly instead of in steps.
*/
public final class PresenceTimeline {
public static final long SAMPLE_MS = 100L;
public static final long DELAY_MS = 300L;
/** A remote menu counts as gone after this long without a fresh payload. */
public static final long STALE_MS = 2500L;
private static final long MAX_BUFFER_MS = 1500L;
/** The same server entry polled twice differs only by clock jitter; payloads closer than this are repeats. */
private static final long MIN_GAP_MS = 40L;
public record Point(float x, float y, boolean pressed, int scroll) {
}
private record Timed(long t, PresenceCodec.Sample sample) {
}
private final ArrayDeque<Timed> buffer = new ArrayDeque<>();
private long lastSend = Long.MIN_VALUE;
private long lastSeen = Long.MIN_VALUE;
private PresenceCodec.Gui meta;
/**
* @param sendMs local-clock time the sender pushed this payload (now minus the server-reported age)
* @return false when this payload is a repeat of one already consumed
*/
public boolean add(long sendMs, PresenceCodec.Gui gui) {
if (sendMs < lastSend + MIN_GAP_MS) return false;
lastSend = sendMs;
lastSeen = sendMs;
meta = gui;
int n = gui.samples().size();
for (int i = 0; i < n; i++) {
long t = sendMs - (long) (n - 1 - i) * SAMPLE_MS;
if (buffer.isEmpty() || t > buffer.peekLast().t()) buffer.addLast(new Timed(t, gui.samples().get(i)));
}
while (!buffer.isEmpty() && sendMs - buffer.peekFirst().t() > MAX_BUFFER_MS) buffer.pollFirst();
return true;
}
public PresenceCodec.Gui meta() {
return meta;
}
/** True while the remote menu is open (a payload not marked closed arrived recently). */
public boolean live(long nowMs) {
return meta != null && !meta.closed() && nowMs - lastSeen < STALE_MS;
}
/** True when any payload (menu open or not) arrived recently, i.e. the player runs LoVisual. */
public boolean present(long nowMs) {
return meta != null && nowMs - lastSeen < STALE_MS * 4;
}
/** Interpolated cursor at {@code nowMs - DELAY_MS}, or null with nothing buffered. */
public Point at(long nowMs) {
if (buffer.isEmpty()) return null;
long t = nowMs - DELAY_MS;
Timed prev = null;
for (Timed next : buffer) {
if (next.t() >= t) {
if (prev == null) return point(next.sample());
float k = (float) (t - prev.t()) / Math.max(1L, next.t() - prev.t());
return new Point(lerp(prev.sample().x(), next.sample().x(), k), lerp(prev.sample().y(), next.sample().y(), k),
next.sample().pressed(), next.sample().scroll());
}
prev = next;
}
return point(prev.sample());
}
private static Point point(PresenceCodec.Sample s) {
return new Point(s.x(), s.y(), s.pressed(), s.scroll());
}
private static float lerp(float a, float b, float k) {
return a + (b - a) * Math.max(0.0f, Math.min(1.0f, k));
}
}

View file

@ -3679,5 +3679,10 @@
"setting.killeffect.blast_color": "Blast color",
"setting.killeffect.blast_color.option.Natural": "Natural",
"setting.killeffect.blast_color.option.Client": "Client theme",
"setting.killeffect.blast_color.option.Custom": "Custom"
"setting.killeffect.blast_color.option.Custom": "Custom",
"module.guipresence.description": "Shows the ClickGUI other LoVisual users have open, and shares yours.",
"setting.guipresence.share": "Share my menu",
"setting.guipresence.show_others": "Show other players' menus",
"setting.guipresence.distance": "Distance",
"setting.guipresence.scale": "Panel size"
}

View file

@ -3470,5 +3470,10 @@
"setting.killeffect.blast_color": "Цвет взрыва",
"setting.killeffect.blast_color.option.Natural": "Природный",
"setting.killeffect.blast_color.option.Client": "Цвет темы",
"setting.killeffect.blast_color.option.Custom": "Свой"
"setting.killeffect.blast_color.option.Custom": "Свой",
"module.guipresence.description": "Показывает открытый ClickGUI других пользователей LoVisual и делится вашим.",
"setting.guipresence.share": "Показывать моё меню",
"setting.guipresence.show_others": "Показывать меню других игроков",
"setting.guipresence.distance": "Дистанция",
"setting.guipresence.scale": "Размер панели"
}

View file

@ -0,0 +1,77 @@
package dev.loki.lovisual.features.platform.presence;
import dev.loki.lovisual.features.platform.presence.PresenceCodec.Gui;
import dev.loki.lovisual.features.platform.presence.PresenceCodec.Sample;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class PresenceTest {
private static Gui gui(int n) {
List<Sample> samples = new ArrayList<>();
for (int i = 0; i < n; i++) samples.add(new Sample(i * 10, 255 - i, i % 2 == 0, i));
return new Gui(2, true, false, false, 7, samples);
}
@Test
void codecRoundTrips() {
Gui in = gui(5);
Gui out = PresenceCodec.decode(PresenceCodec.encode(in));
assertEquals(in, out);
}
@Test
void codecKeepsClosedFlagAndNoCategory() {
Gui in = new Gui(-1, false, true, true, PresenceCodec.NO_MODULE, List.of());
assertEquals(in, PresenceCodec.decode(PresenceCodec.encode(in)));
}
@Test
void encodedPayloadFitsServerLimit() {
assertTrue(PresenceCodec.encode(gui(20)).length() <= 48);
}
@Test
void decodeRejectsGarbage() {
assertNull(PresenceCodec.decode(null));
assertNull(PresenceCodec.decode("!!!"));
assertNull(PresenceCodec.decode(Base64.getEncoder().encodeToString(new byte[]{9, 0, 0, 0})));
assertNull(PresenceCodec.decode(Base64.getEncoder().encodeToString(new byte[]{1, 0, 0, 5})));
}
@Test
void timelineInterpolatesBetweenSamples() {
PresenceTimeline tl = new PresenceTimeline();
Gui g = new Gui(0, false, false, false, 255, List.of(new Sample(0, 0, false, 0), new Sample(100, 200, true, 0)));
assertTrue(tl.add(1000, g));
PresenceTimeline.Point p = tl.at(1000 + PresenceTimeline.DELAY_MS - 50);
assertEquals(50.0f, p.x(), 1e-3f);
assertEquals(100.0f, p.y(), 1e-3f);
}
@Test
void timelineIgnoresRepeatedPayloadsAndGoesStale() {
PresenceTimeline tl = new PresenceTimeline();
Gui g = gui(2);
assertTrue(tl.add(5000, g));
assertFalse(tl.add(5000, g));
assertFalse(tl.add(4000, g));
assertTrue(tl.live(5000 + 100));
assertFalse(tl.live(5000 + PresenceTimeline.STALE_MS + 1));
assertTrue(tl.present(5000 + PresenceTimeline.STALE_MS + 1));
}
@Test
void closedMenuIsNotLiveButStillPresent() {
PresenceTimeline tl = new PresenceTimeline();
tl.add(100, new Gui(-1, false, false, true, 255, List.of()));
assertFalse(tl.live(200));
assertTrue(tl.present(200));
assertNull(tl.at(200));
}
}

25
server-plugin/.classpath Normal file
View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/java">
<attributes>
<attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-25/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

34
server-plugin/.project Normal file
View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>lovisual-policy-server-plugin</name>
<comment>Project server-plugin created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
<filteredResources>
<filter>
<id>1790864025951</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>

View file

@ -0,0 +1,13 @@
arguments=--init-script /home/loki/.eclipse/2139989682_linux_gtk_x86_64/configuration/org.eclipse.osgi/169/0/.cp/gradle/init/init.gradle
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/usr/share/java/gradle))
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/usr/lib/jvm/java-21-openjdk
jvm.arguments=
offline.mode=false
override.workspace.settings=true
show.console.view=true
show.executions.view=true

View file

@ -0,0 +1,4 @@
eclipse.preferences.version=1
org.eclipse.jdt.core.compiler.codegen.targetPlatform=25
org.eclipse.jdt.core.compiler.compliance=25
org.eclipse.jdt.core.compiler.source=25