design: native* 물리 조작 계층 확정 + C-7("부기가 물리보다 먼저") 역전
주입 op 셋(mountInst/unmountInst/disposeInst)으로는 Move/Swap을 아예 표현할 수 없다는 지적에서 시작해 물리 조작 계층 전체를 재설계했다. 층위 정의(사용자): raw*는 그 Slot 스코프 안의 연산(평탄화 전, _elements 인덱스), native*는 확정된 offset/length로 표현되는 물리 트리 연산(평탄화 후, 절대 좌표). 표면 여섯: nativeInsert (target, offset, elements) nativeExtract(target, offset, elements, newElements?) -- 빼되 살림 nativeRemove (target, offset, elements, newElements?) -- 빼면서 파괴 nativeMove (target, fromOffset, elements, toOffset) nativeSwap (target, offsetA, elementsA, offsetB, elementsB) nativeDispose(element) -- 트리 밖 값 파괴 - Replace는 별도 op이 아니라 newElements가 있는 Remove/Extract(Splice도 동일) — 제거+삽입을 한 호출로 합쳐 리플로우 2회와 그 사이 인덱스가 어긋난 창을 없앤다 - 파괴/비파괴를 불리언이 아니라 이름으로 가름 — 공개 CRUD의 Remove/Extract 어휘를 물려받고, Roblox의 "Parent=nil 없이 그 자리에서 Destroy" 융합을 연다 - 빠지는 요소는 반드시 배열로 넘김 — (target, offset, count)로 대상을 찾을 수 있는 건 DOM뿐이고 Roblox는 자식이 순서 없는 집합이라 offset 역조회가 안 됨 - nativeSwap은 별도 — Move는 사이를 전부 밀지만 Swap은 가운데 고정 - 미주입이면 에러가 아니라 조합 폴백(addTag 계열과 갈리는 지점) - 전제: 한 Slot의 물리 자식은 부모 안에서 연속 구간을 차지한다 그 여파로 4라운드 C-7 일반 계약이 역전됨 — "Length를 먼저 올려 밀어내고 그 공간에 넣는다"는 그림은 base에 물리적으로 자리를 비워둘 수단이 없어 성립하지 않는다. 규칙이 "빼기는 물리 먼저/넣기는 부기 먼저" 두 얼굴에서 "자기 자리를 정하는 것(setOffsetSource) 먼저 / 뒤를 미는 것(setLength→ recompute) 나중" 하나로 줄었다. 배치 경로(materializeSlotTree→mountSlotTree)의 부기-전량-먼저는 C6가 요구하는 별개 사안이라 그대로. 원문은 archive/bookkeeping-before-physical-reversed.md. 같이: getOffsetAt을 사용자 의사코드대로 단일 함수 + invalidAfter로 정정 (무효화는 min(invalidAfter, i) 하나, recompute도 그 캐시 위에 얹혀 O(N)). doc-check.py ERROR 0. 상세는 qa-request/pre-implementation-qa-round5-followup.md의 L절. Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
c6fdf1b348
commit
a4b517aee2
11 changed files with 362 additions and 145 deletions
|
|
@ -126,6 +126,7 @@
|
||||||
| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(2026-08-14 다섯 번째 세션에 폐기됐다가 열한 번째 세션에 별도 진입점으로 재도입 — `canExecute`와 판정 로직만 공유)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State<Slot?>` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 |
|
| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(2026-08-14 다섯 번째 세션에 폐기됐다가 열한 번째 세션에 별도 진입점으로 재도입 — `canExecute`와 판정 로직만 공유)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State<Slot?>` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 |
|
||||||
| `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` |
|
| `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` |
|
||||||
| `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State<State<T>>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 |
|
| `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State<State<T>>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 |
|
||||||
|
| `bookkeeping-before-physical-reversed.md` | **[역전됨, 2026-08-21 구현 전 QA 5라운드]** "부기가 물리 트리 조작보다 항상 먼저 끝난다"(4라운드 `C-7` 일반 계약) — `native*` 주입 op 계층이 확정되며 폐기. **base엔 물리적으로 자리를 비워둘 수단이 없고**(밀어내는 주체는 백엔드의 삽입 연산 자신), 그래서 "빼기는 물리 먼저/넣기는 부기 먼저" 두 얼굴 대신 **"자기 자리를 정하는 것 먼저(`setOffsetSource`) / 뒤를 미는 것 나중(`setLength`→`recompute`)"** 하나로 줄었다. 배치 경로의 부기-전량-먼저는 C6가 요구하는 별개 사안이라 그대로. 현행은 `base/dispatch-core-plan.md`의 "일반 계약 — 물리와 부기의 순서" 절 |
|
||||||
| `bindlifetime-slot-owner-reversed.md` | **[역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`]** "`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하고 `isBoundAlive`에 세 번째 분기를 둬라"(4라운드 `D-56`) — `Dispatch.setLength`가 **부기 키와 생명주기 앵커를 따로 받도록** 바뀌면서 요구사항 자체가 사라짐(앵커는 언제나 물리 target, 모든 호출부가 이미 그 값을 안다). 부수로 형태 미정이던 "세 번째 분기" 항목도 같이 닫힘. 현행은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단 |
|
| `bindlifetime-slot-owner-reversed.md` | **[역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`]** "`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하고 `isBoundAlive`에 세 번째 분기를 둬라"(4라운드 `D-56`) — `Dispatch.setLength`가 **부기 키와 생명주기 앵커를 따로 받도록** 바뀌면서 요구사항 자체가 사라짐(앵커는 언제나 물리 target, 모든 호출부가 이미 그 값을 안다). 부수로 형태 미정이던 "세 번째 분기" 항목도 같이 닫힘. 현행은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단 |
|
||||||
| `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime`이 `.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`(**2026-08-14 열한 번째 세션에 별도 진입점으로 재도입 — 이 문서 하단에 addendum**)와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` |
|
| `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime`이 `.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`(**2026-08-14 열한 번째 세션에 별도 진입점으로 재도입 — 이 문서 하단에 addendum**)와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` |
|
||||||
| `tag-attribute-load-time-registration-reversed.md` | **[역전됨, 2026-08-14 열두 번째 세션 신설, 2026-08-18 재역전으로 원래 결론 쪽이 다시 현행]** "`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 quad-base 모듈 로드 시점에 스스로 등록한다"(열한 번째 세션 "네 번째, 최종 정정") — 2026-08-14 열두 번째 세션엔 `base/lifecycle-pattern.md`가 거부한 `InitNamespace`류 top-level 부작용 패턴과 같은 클래스라며 틀렸다고 보고, 등록 주체를 백엔드 팩토리로 정정했었음. **그런데 2026-08-18 구현 전 QA에서 다시 뒤집힘(`D-7`)** — 백엔드 팩토리가 등록 주체면 quad-roblox를 아예 안 붙인 상태에서 "provider가 초기화됐는지" 안내 경로 자체가 안 돌기 때문. **지금 현행은 이 문서가 역전이라 부르던 원래 결론(quad-base 자신이 로드 시점에 등록)과 같은 방향** — 상세는 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절, 재역전 논의 원문은 `qa-request/pre-implementation-qa-round1.md`의 "D-7" 절 |
|
| `tag-attribute-load-time-registration-reversed.md` | **[역전됨, 2026-08-14 열두 번째 세션 신설, 2026-08-18 재역전으로 원래 결론 쪽이 다시 현행]** "`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 quad-base 모듈 로드 시점에 스스로 등록한다"(열한 번째 세션 "네 번째, 최종 정정") — 2026-08-14 열두 번째 세션엔 `base/lifecycle-pattern.md`가 거부한 `InitNamespace`류 top-level 부작용 패턴과 같은 클래스라며 틀렸다고 보고, 등록 주체를 백엔드 팩토리로 정정했었음. **그런데 2026-08-18 구현 전 QA에서 다시 뒤집힘(`D-7`)** — 백엔드 팩토리가 등록 주체면 quad-roblox를 아예 안 붙인 상태에서 "provider가 초기화됐는지" 안내 경로 자체가 안 돌기 때문. **지금 현행은 이 문서가 역전이라 부르던 원래 결론(quad-base 자신이 로드 시점에 등록)과 같은 방향** — 상세는 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절, 재역전 논의 원문은 `qa-request/pre-implementation-qa-round1.md`의 "D-7" 절 |
|
||||||
|
|
|
||||||
64
.claude/archive/bookkeeping-before-physical-reversed.md
Normal file
64
.claude/archive/bookkeeping-before-physical-reversed.md
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# [역전됨, 2026-08-21] "부기가 물리 트리 조작보다 항상 먼저 끝난다"
|
||||||
|
|
||||||
|
**상태**: 역전됨. 2026-08-20 구현 전 QA 4라운드 `C-7`에서 **모든 `raw*`가 따르는
|
||||||
|
일반 계약**으로 승격됐다가, **2026-08-21 5라운드에 `native*` 주입 op 계층이
|
||||||
|
확정되면서 폐기**됐다.
|
||||||
|
|
||||||
|
**왜 뒤집혔나**: 이 계약은 *"`Length`를 먼저 올려 뒤 형제를 밀어내고 비워진
|
||||||
|
공간에 넣는다"*는 그림 위에 서 있었는데, **base에는 물리적으로 자리를 비워둘
|
||||||
|
수단이 없다.** 미는 주체는 언제나 백엔드의 삽입 연산 자신이다 — 사용자 지적:
|
||||||
|
*"밀어내고 null을 넣어둘 수도 없는데, 그리고 nativeInsert 라는것 자체가
|
||||||
|
밀어내기 동작을 강제하는데, 그렇다면 length 를 나중에 설정한 다음 offset들이
|
||||||
|
무시되어야함."*
|
||||||
|
|
||||||
|
**무엇이 남았나**: "빼기는 물리 먼저 / 넣기는 부기 먼저"라는 두 얼굴 대신
|
||||||
|
**"자기 자리를 정하는 것 먼저(`setOffsetSource`) / 뒤를 미는 것 나중
|
||||||
|
(`setLength` → `recompute`)"** 하나로 줄었다. 배치 경로(`materializeSlotTree` →
|
||||||
|
`mountSlotTree`)가 부기 전량을 먼저 끝내는 것은 **C6가 요구하는 별개 사안**이라
|
||||||
|
그대로다. 지금 유효한 서술은 `base/dispatch-core-plan.md`의 "일반 계약 — 물리와
|
||||||
|
부기의 순서" 절.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 역전 전 원문
|
||||||
|
|
||||||
|
### ⭐ 일반 계약 — 부기가 물리 트리 조작보다 항상 먼저 끝난다 (2026-08-20 구현 전 QA 4라운드 `C-7` 승격)
|
||||||
|
|
||||||
|
**지금까지 이 규칙은 `rawAdd` 한 곳에만 적혀 있었다**(바로 아래 "동기 순서"
|
||||||
|
문단). `rawRemove`/`rawUnmount`는 의사코드가 우연히 같은 모양이었을 뿐
|
||||||
|
계약화돼 있지 않았고, `Splice`는 물리 detach/attach와 부기의 선후가 **아예
|
||||||
|
안 적혀 있었다.** 사용자 판정으로 **모든 `raw*`가 따르는 일반 계약으로
|
||||||
|
승격**한다(*"각각의 동작에 따라 다른 동작 보다, 일관성 있는 동작을 제공하는게
|
||||||
|
나아보이고, 이것을 단순 일반 계약 승격으로 도달 될 수 있기 때문"*).
|
||||||
|
|
||||||
|
> **계약**: 어떤 CRUD/재조정 연산이든 **`Length`/`offset` 부기 갱신이 물리
|
||||||
|
> 트리 조작(`Parent` 대입/해제)보다 먼저 완료**돼야 한다. 즉 "**먼저 밀어내고
|
||||||
|
> 그 공간에 넣는다**" / "**먼저 비운 걸 반영하고 그 다음 당긴다**".
|
||||||
|
|
||||||
|
- **왜 일반 계약이어야 하나**: 백엔드가 "밀어내기"를 물리적으로 구현해야
|
||||||
|
하는 경우(DOM `insertBefore` 밖의 백엔드 등), **부기가 이미 정확하다**는 걸
|
||||||
|
전제할 수 있어야 자기 일을 할 수 있다. 연산마다 선후가 다르면 백엔드
|
||||||
|
작성자가 매번 다시 확인해야 한다.
|
||||||
|
- **각 연산에 적용하면**:
|
||||||
|
- `rawAdd` — 부기(`setOffsetSource`/`setLength` 등록 → `recompute`) 완료 →
|
||||||
|
물리 마운트(`mountInst`). **[정정, 2026-08-21 5라운드 `C-2`]** 예전엔 여기
|
||||||
|
`Length:Set(newCount)`라고 적혀 있었으나 **틀렸다** — 아래 문단 참고.
|
||||||
|
- `rawRemove`/`rawUnmount`/**`rawDetach`**(**[2026-08-21]** `Detach`
|
||||||
|
경로용으로 신설된 세 번째 형제 — 소유권을 **유지**한 채 언마운트만
|
||||||
|
한다는 점만 다르고 순서는 같음, `base/slot-plan.md`) — 파괴/언마운트 →
|
||||||
|
`spliceArraysDown` →
|
||||||
|
`recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과
|
||||||
|
어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직
|
||||||
|
트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는
|
||||||
|
물리 먼저, 넣기는 부기 먼저"**가 실제로는 같은 원칙(항상 **좁은 쪽이
|
||||||
|
먼저**)의 두 얼굴이다. 문서화 시 이 대칭으로 적을 것.
|
||||||
|
- `Splice` — 제거 구간과 삽입 구간이 겹치므로 위 두 규칙을 그대로 이어
|
||||||
|
붙이면 된다(제거는 물리 먼저, 삽입은 부기 먼저). **shift/recompute를
|
||||||
|
1회로 묶는다는 최적화는 그 사이에서만** 일어난다.
|
||||||
|
- `rawMove`/`rawSwap` — `Parent`를 안 건드리므로 이 계약의 대상이 아님.
|
||||||
|
- **⚠️ 프레임 경계는 어차피 안 낀다** — `process`/`attachSlot` 체인 도중
|
||||||
|
코루틴 yield가 금지돼 있으므로(위 "Handler 작성 체크리스트" 9번) 이
|
||||||
|
순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 이
|
||||||
|
계약은 "안 지키면 깜빡인다"가 아니라 **"백엔드가 전제할 수 있게 하나로
|
||||||
|
고정한다"**가 진짜 이유다.
|
||||||
|
|
||||||
|
|
@ -291,7 +291,7 @@ quad/
|
||||||
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
|
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
|
||||||
└── src/
|
└── src/
|
||||||
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
|
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
|
||||||
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드, 이름 가칭] mountInst(target,element,index)/unmountInst(element)**(물리 트리 조작 — base는 `Parent`를 모른다, 확정 시 이 목록에 정식 등재) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
|
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드, 이름 가칭] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백** (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
|
||||||
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
|
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
|
||||||
├── Handlers/
|
├── Handlers/
|
||||||
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)
|
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)
|
||||||
|
|
|
||||||
|
|
@ -1533,53 +1533,58 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `
|
||||||
-- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환.
|
-- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환.
|
||||||
-- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — mountInst의 삽입 위치,
|
-- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — mountInst의 삽입 위치,
|
||||||
-- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다.
|
-- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다.
|
||||||
function Dispatch.getOffsetAt(ownerKey, i)
|
function Dispatch.getOffsetAt(ownerKey, at)
|
||||||
local bk = getBookkeeping(ownerKey)
|
local bk = getBookkeeping(ownerKey)
|
||||||
-- [2026-08-21 사용자 제안] **접두합 캐시 + "이 인덱스 초과부터 무효" 마커.**
|
-- [2026-08-21 사용자 제안, 같은 날 의사코드 정정] **단일 함수 + 접두합 캐시.**
|
||||||
-- 매번 1..i-1을 다시 더하면 호출당 O(i)라 reconcile 전체가 O(N²)가 된다.
|
-- `bk.offsetCache[i]` = i 자리의 절대 offset, `bk.invalidAfter` = **여기까지는
|
||||||
-- 캐시된 접두합(`bk.offsetCache[j]` = j 자리의 절대 offset)은 **그 앞쪽이
|
-- 캐시가 유효**(그 뒤부터 다시 누적해야 함). 함수를 둘로 나누지 않는다 —
|
||||||
-- 안 바뀐 한 계속 유효**하므로, 무효화는 "바뀐 자리부터 뒤"만 하면 된다.
|
-- 이 하나가 필요한 만큼만 앞으로 이어붙이므로, 순차 호출이면 한 칸씩만
|
||||||
local from = bk.offsetDirtyFrom or 1 -- 이 인덱스부터가 무효
|
-- 늘어나 전체가 O(N)이 된다(사용자: *"그러면 알아서 순차적으로 합캐시가
|
||||||
if i < from then
|
-- 처리됨"*).
|
||||||
return bk.offsetCache[i] -- 앞쪽은 그대로 유효 — O(1)
|
if bk.invalidAfter == 0 then
|
||||||
|
-- 시작점 — 1번 자리의 offset은 이 owner의 베이스 그 자체.
|
||||||
|
bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
|
||||||
|
bk.invalidAfter = 1
|
||||||
end
|
end
|
||||||
-- 무효 구간만 이어붙여 다시 계산한다(유효한 마지막 자리의 값에서 출발).
|
if at <= bk.invalidAfter then
|
||||||
local sum = if from > 1 then bk.offsetCache[from - 1] + contribution(bk, from - 1)
|
return bk.offsetCache[at] -- 유효 구간 — O(1)
|
||||||
else (if isSlot(ownerKey) then ownerKey.Offset:Get() else 0)
|
|
||||||
for j = from, i - 1 do
|
|
||||||
bk.offsetCache[j] = sum
|
|
||||||
sum += contribution(bk, j) -- lengthList[j](State면 :Get())
|
|
||||||
end
|
end
|
||||||
bk.offsetCache[i] = sum
|
local cur = bk.offsetCache[bk.invalidAfter]
|
||||||
bk.offsetDirtyFrom = i + 1 -- 여기까지는 이제 유효
|
for i = bk.invalidAfter, at - 1 do
|
||||||
return sum
|
cur += contribution(bk, i) -- lengthList[i](State면 :Get())
|
||||||
|
bk.offsetCache[i + 1] = cur -- **지금 자리의 길이가 다음 자리의 offset을 정한다**
|
||||||
|
end
|
||||||
|
bk.invalidAfter = at -- 여기까지 유효해짐
|
||||||
|
return cur
|
||||||
end
|
end
|
||||||
|
```
|
||||||
|
|
||||||
**⭐ [2026-08-21 사용자 제안] `getOffsetAt`의 접두합 캐시 — 무효화 규칙**
|
**⭐ [2026-08-21] 캐시 무효화 — 규칙이 하나다**
|
||||||
|
|
||||||
`offsetCache`/`offsetDirtyFrom`이 성립하려면 **"앞이 안 바뀌면 뒤도 안 바뀐다"**는
|
`bk.invalidAfter`는 **"이 인덱스까지는 캐시가 유효"**를 뜻하고, 무효화는 전부
|
||||||
단조성이 지켜져야 한다. 그래서 아래 셋 중 하나라도 일어나면 `offsetDirtyFrom`을
|
같은 모양이다 — **`bk.invalidAfter = math.min(bk.invalidAfter, i)`**(앞으로만
|
||||||
그 인덱스로 내린다(더 작은 값으로만 갱신):
|
당긴다):
|
||||||
|
|
||||||
1. **`setLength(ownerKey, i, ...)`** — `i` 자리의 기여도가 바뀌므로 `i + 1`부터
|
| 무엇이 바뀌나 | 어디까지 당기나 | 왜 |
|
||||||
무효(그 자리 자신의 offset은 안 변한다). State 길이가 나중에 emit할 때도 같다.
|
|---|---|---|
|
||||||
2. **`spliceArraysUp`/`spliceArraysDown`(자리 삽입·삭제)** — 그 인덱스부터 전부
|
| `setLength(ownerKey, i, ...)`, 그리고 그 State가 나중에 emit할 때 | `i` | **`i` 자리의 offset은 안 바뀐다**(그건 `1..i-1`의 합) — 바뀌는 건 그 **뒤**뿐. 사용자: *"정확히 입력받은 자신 인덱스까지 당김"* |
|
||||||
밀리므로 그 자리부터 무효.
|
| `spliceArraysUp`/`spliceArraysDown`(자리 삽입·삭제) | `i` | 같은 이유 — 삽입/삭제 후에도 `i` 자리의 offset은 여전히 `1..i-1`의 합이다 |
|
||||||
3. **owner의 베이스가 바뀜**(`ownerKey.Offset` 변경 = `_baseObserver`가 도는
|
| owner의 베이스 변경(`ownerKey.Offset`이 바뀜 = `_baseObserver`가 도는 순간) | `0` | 1번 자리부터 전부 다시 |
|
||||||
그 순간) — **전체 무효**(`offsetDirtyFrom = 1`).
|
|
||||||
|
|
||||||
**⚠️ 아직 안 정한 것**: `recompute`가 이미 전체를 순회하며 같은 접두합을
|
**`recompute`도 이 캐시 위에 얹힌다** — `1..N`을 순서대로 도는 함수라 매 자리에서
|
||||||
계산하므로, **그 순회가 캐시를 그대로 채우게 할지**(그러면 `recompute` 직후엔
|
`getOffsetAt`이 한 칸씩만 이어붙이므로 전체가 O(N)이고, 별도 접두합 로직을 따로
|
||||||
캐시가 항상 완전) 아니면 두 경로를 분리해 둘지. 전자가 낭비가 없어 보이지만
|
두지 않는다. (그래서 "`recompute`가 캐시를 채울지 말지"라는 갈래 자체가 없어졌다 —
|
||||||
`recompute`는 `Set` 캐스케이드를 유발할 수 있어 호출 빈도가 다르다 —
|
사용자: *"함수를 나눠야할 이유를 모르겠음. 하나로 두는게 나아보임."*)
|
||||||
구현 시 확인.
|
|
||||||
|
|
||||||
|
```lua
|
||||||
local function recompute(ownerKey, bk)
|
local function recompute(ownerKey, bk)
|
||||||
-- [2026-08-21 G절] `0`이 아니라 이 owner의 베이스에서 시작한다.
|
-- [2026-08-21 G절] `0`이 아니라 이 owner의 베이스에서 시작한다.
|
||||||
-- 베이스는 별도로 저장하지 않는다 — Slot이면 자기 `.Offset`이 곧 그 값이고
|
-- 베이스는 별도로 저장하지 않는다 — Slot이면 자기 `.Offset`이 곧 그 값이고
|
||||||
-- (부모가 먼저 설정해두므로 이미 정확하다), 최상위 물리 inst엔 베이스가
|
-- (부모가 먼저 설정해두므로 이미 정확하다), 최상위 물리 inst엔 베이스가
|
||||||
-- 아예 없어 항상 0이다. 위 `getOffsetAt`과 같은 식.
|
-- 아예 없어 항상 0이다. 위 `getOffsetAt`과 같은 식.
|
||||||
local base = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
|
-- [2026-08-21] offset 값은 위 `getOffsetAt`(접두합 캐시)에서 받는다 —
|
||||||
|
-- 여기서 따로 누적하지 않는다. 순서대로 도는 순회라 캐시가 한 칸씩만 늘어나
|
||||||
|
-- 전체 O(N).
|
||||||
local sum = 0
|
local sum = 0
|
||||||
-- [2026-08-21 5라운드 감사] `bk.N or 0` — **빈 Slot 크래시 방어**.
|
-- [2026-08-21 5라운드 감사] `bk.N or 0` — **빈 Slot 크래시 방어**.
|
||||||
-- `bk.N`은 `setLength`가 처음 불릴 때 생기므로(`bk.N = math.max(bk.N or 0, i)`),
|
-- `bk.N`은 `setLength`가 처음 불릴 때 생기므로(`bk.N = math.max(bk.N or 0, i)`),
|
||||||
|
|
@ -1598,8 +1603,9 @@ local function recompute(ownerKey, bk)
|
||||||
if offset == nil then
|
if offset == nil then
|
||||||
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
|
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
|
||||||
end
|
end
|
||||||
if offset ~= None and offset:Get() ~= base + sum then -- 실제로 다를 때만 Set
|
local abs = Dispatch.getOffsetAt(ownerKey, i) -- 절대 offset(캐시 경유)
|
||||||
offset:Set(base + sum)
|
if offset ~= None and offset:Get() ~= abs then -- 실제로 다를 때만 Set
|
||||||
|
offset:Set(abs)
|
||||||
end
|
end
|
||||||
local v = bk.lengthList[i]
|
local v = bk.lengthList[i]
|
||||||
sum += (if isState(v) then v:Get() else v)
|
sum += (if isState(v) then v:Get() else v)
|
||||||
|
|
@ -1838,45 +1844,39 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
|
||||||
아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은
|
아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은
|
||||||
톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것.
|
톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것.
|
||||||
|
|
||||||
### ⭐ 일반 계약 — 부기가 물리 트리 조작보다 항상 먼저 끝난다 (2026-08-20 구현 전 QA 4라운드 `C-7` 승격)
|
### ⭐ 일반 계약 — 물리와 부기의 순서 (2026-08-20 `C-7`로 승격, **2026-08-21 5라운드에 재정의**)
|
||||||
|
|
||||||
**지금까지 이 규칙은 `rawAdd` 한 곳에만 적혀 있었다**(바로 아래 "동기 순서"
|
> **🔄 [역전됨, 2026-08-21] "부기가 물리 트리 조작보다 항상 먼저 끝난다"는
|
||||||
문단). `rawRemove`/`rawUnmount`는 의사코드가 우연히 같은 모양이었을 뿐
|
> 계약은 폐기됐다.** 원문은 `archive/bookkeeping-before-physical-reversed.md`.
|
||||||
계약화돼 있지 않았고, `Splice`는 물리 detach/attach와 부기의 선후가 **아예
|
|
||||||
안 적혀 있었다.** 사용자 판정으로 **모든 `raw*`가 따르는 일반 계약으로
|
|
||||||
승격**한다(*"각각의 동작에 따라 다른 동작 보다, 일관성 있는 동작을 제공하는게
|
|
||||||
나아보이고, 이것을 단순 일반 계약 승격으로 도달 될 수 있기 때문"*).
|
|
||||||
|
|
||||||
> **계약**: 어떤 CRUD/재조정 연산이든 **`Length`/`offset` 부기 갱신이 물리
|
**왜 뒤집혔나**: 그 계약은 *"`Length`를 먼저 올려 뒤 형제를 밀어내고, 비워진
|
||||||
> 트리 조작(`Parent` 대입/해제)보다 먼저 완료**돼야 한다. 즉 "**먼저 밀어내고
|
그 공간에 넣는다"*는 그림 위에 서 있었는데, **base에는 물리적으로 자리를
|
||||||
> 그 공간에 넣는다**" / "**먼저 비운 걸 반영하고 그 다음 당긴다**".
|
비워둘 수단이 없다**(자리를 비워 `null`을 꽂아둘 수도 없다). 미는 주체는
|
||||||
|
언제나 백엔드의 삽입 연산 자신이다 — `native*` 계층이 들어오면서 이게
|
||||||
|
명확해졌다(사용자: *"nativeInsert 라는것 자체가 밀어내기 동작을 강제하는데,
|
||||||
|
그렇다면 length 를 나중에 설정한 다음 offset들이 무시되어야함. 일종의 웹 돔과
|
||||||
|
같은 동작을 내도록 강제하는 시스템"*).
|
||||||
|
|
||||||
- **왜 일반 계약이어야 하나**: 백엔드가 "밀어내기"를 물리적으로 구현해야
|
**지금의 계약 — 셋으로 줄었다**:
|
||||||
하는 경우(DOM `insertBefore` 밖의 백엔드 등), **부기가 이미 정확하다**는 걸
|
|
||||||
전제할 수 있어야 자기 일을 할 수 있다. 연산마다 선후가 다르면 백엔드
|
1. **물리 조작은 `native*`가 전담하고, 밀고 당기는 건 그 op 자신이 한다**
|
||||||
작성자가 매번 다시 확인해야 한다.
|
(DOM식 의미론을 시스템 전체가 강제).
|
||||||
- **각 연산에 적용하면**:
|
2. **base의 offset 부기는 "배치 지시"가 아니라 계산값이다** — 이미 배치된
|
||||||
- `rawAdd` — 부기(`setOffsetSource`/`setLength` 등록 → `recompute`) 완료 →
|
것을 옮기지 않는다(`base/slot-plan.md`의 웹 백엔드 문단, 5라운드 확정).
|
||||||
물리 마운트(`mountInst`). **[정정, 2026-08-21 5라운드 `C-2`]** 예전엔 여기
|
3. **순서 규칙은 "자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중"** 하나다:
|
||||||
`Length:Set(newCount)`라고 적혀 있었으나 **틀렸다** — 아래 문단 참고.
|
- **`setOffsetSource`는 먼저** — 그 자리의 offset은 `1..i-1`의 합이라
|
||||||
- `rawRemove`/`rawUnmount`/**`rawDetach`**(**[2026-08-21]** `Detach`
|
**자기 삽입/제거로 안 변한다.** 게다가 삽입 위치 계산이 이 값이고,
|
||||||
경로용으로 신설된 세 번째 형제 — 소유권을 **유지**한 채 언마운트만
|
Slot이면 `activateList`가 곧바로 그 값을 쓴다(C1).
|
||||||
한다는 점만 다르고 순서는 같음, `base/slot-plan.md`) — 파괴/언마운트 →
|
- **`setLength` → `recompute`는 나중** — 이게 **뒤를 미는** 쪽이다.
|
||||||
`spliceArraysDown` →
|
- 그래서 `rawAdd`는 `spliceArraysUp` → `setOffsetSource` → `nativeInsert`
|
||||||
`recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과
|
→ `setLength` → `recompute` 순서다(`base/slot-plan.md`).
|
||||||
어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직
|
4. **배치 경로는 여전히 "부기 전량 먼저"** — `materializeSlotTree` →
|
||||||
트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는
|
`mountSlotTree` 분해는 그대로다. 그때는 삽입 위치가 전부 확정된 뒤에
|
||||||
물리 먼저, 넣기는 부기 먼저"**가 실제로는 같은 원칙(항상 **좁은 쪽이
|
물리가 몰리는 것이고, C6("부모에게 미는 길이는 최종값")가 그걸 요구한다.
|
||||||
먼저**)의 두 얼굴이다. 문서화 시 이 대칭으로 적을 것.
|
위 3번과 모순이 아니다 — 3번은 **단건 경로**의 규칙이다.
|
||||||
- `Splice` — 제거 구간과 삽입 구간이 겹치므로 위 두 규칙을 그대로 이어
|
- **⚠️ 프레임 경계는 여전히 안 낀다**(yield 금지) — 그래서 이 순서 변경으로
|
||||||
붙이면 된다(제거는 물리 먼저, 삽입은 부기 먼저). **shift/recompute를
|
사용자에게 보이는 중간 상태가 생기지 않는다. 옛 계약이 내세웠던 "한 프레임
|
||||||
1회로 묶는다는 최적화는 그 사이에서만** 일어난다.
|
순서가 깨진 채 노출될 위험"은 그때 이미 근거에서 빠져 있었다.
|
||||||
- `rawMove`/`rawSwap` — `Parent`를 안 건드리므로 이 계약의 대상이 아님.
|
|
||||||
- **⚠️ 프레임 경계는 어차피 안 낀다** — `process`/`attachSlot` 체인 도중
|
|
||||||
코루틴 yield가 금지돼 있으므로(위 "Handler 작성 체크리스트" 9번) 이
|
|
||||||
순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 이
|
|
||||||
계약은 "안 지키면 깜빡인다"가 아니라 **"백엔드가 전제할 수 있게 하나로
|
|
||||||
고정한다"**가 진짜 이유다.
|
|
||||||
|
|
||||||
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
|
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
|
||||||
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:
|
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:
|
||||||
|
|
|
||||||
|
|
@ -49,39 +49,60 @@ inst, inst, k)` 한 줄로 축약됨** — `Dispatch.setLength`/`activateList`
|
||||||
호출은 `attachSlot` 내부로 옮겨감(로직은 그대로, 재귀 가능하도록만
|
호출은 `attachSlot` 내부로 옮겨감(로직은 그대로, 재귀 가능하도록만
|
||||||
일반화됨), 상세는 아래 "Slot-in-Slot 중첩" 절 참고.
|
일반화됨), 상세는 아래 "Slot-in-Slot 중첩" 절 참고.
|
||||||
|
|
||||||
### ⭐ 물리 조작은 주입 op다 — base는 `Parent`를 모른다 (2026-08-21 구현 전 QA 5라운드 `C-1`)
|
### ⭐ 물리 조작은 주입 op다 — `native*` 계층 (2026-08-21 구현 전 QA 5라운드, 같은 날 확장)
|
||||||
|
|
||||||
**사용자 지적**: *"element.Parent = self._mountedInst 부분은 실제 엔진이
|
**사용자 지적**: *"element.Parent = self._mountedInst 부분은 실제 엔진이
|
||||||
구현하게 되는 crud 셋을 사용하게 되어야할것이다. slot 의 해당 동작은 base
|
구현하게 되는 crud 셋을 사용하게 되어야할것이다. slot 의 해당 동작은 base
|
||||||
이므로 parent 를 모른다."* 맞다 — 위 경계 문단이 "실제 트리 조작은 백엔드"라고
|
이므로 parent 를 모른다."* 맞다 — 위 경계 문단이 "실제 트리 조작은 백엔드"라고
|
||||||
이미 말해뒀는데, **의사코드는 계속 `element.Parent = ...` / `element:Destroy()`로
|
이미 말해뒀는데 의사코드는 계속 `element.Parent = ...` / `element:Destroy()`로
|
||||||
Roblox 어휘를 직접 쓰고 있었다.** 이 문서의 의사코드를 전부 주입 op 호출로
|
Roblox 어휘를 직접 쓰고 있었다. 이 문서의 의사코드를 전부 주입 op 호출로 바꿨다.
|
||||||
바꿨다(로직은 그대로, 표기만 정정):
|
|
||||||
|
|
||||||
| op | 언제 | 대응하는 경계 훅 |
|
**층위 정의(사용자)**: **`raw*`는 그 Slot 스코프 안의 연산**(평탄화 전,
|
||||||
|---|---|---|
|
`_elements` 인덱스)이고, **`native*`는 확정된 offset/length로 표현되는 물리 트리
|
||||||
| `mountInst(target, element, index)` | 요소를 물리 트리에 붙일 때(`index` = 0-based 절대 offset, 아래 항목) | mount(`Add`) |
|
연산**(평탄화 후, 절대 좌표)이다.
|
||||||
| `unmountInst(element)` | 물리 트리에서만 떼어낼 때(파괴 아님) | unmount(`Remove`/`Extract`) |
|
|
||||||
| `disposeInst(element)` | 요소를 실제로 파괴할 때 | 이미 확정된 주입 op(아래 `dispose` 절) |
|
|
||||||
|
|
||||||
- **reposition(`Move`/`Swap`)은 base가 "Parent를 안 건드린다"는 계약만
|
```lua
|
||||||
강제**하므로 별도 op이 없다(위 문단 그대로) — 백엔드가 `LayoutOrder` 정렬에
|
nativeInsert (target, offset, elements) -- 삽입(자신이 밀어냄)
|
||||||
기대 no-op으로 둘 수도 있다.
|
nativeExtract(target, offset, elements, newElements?) -- 빼되 **살림** (+그 자리에 교체 삽입)
|
||||||
- **⭐ [확정, 2026-08-21 5라운드 G절] `mountInst(target, element, index)` —
|
nativeRemove (target, offset, elements, newElements?) -- 빼면서 **파괴** (+그 자리에 교체 삽입)
|
||||||
index는 절대 offset(0-based)이다.** 원래는 "형제 순서는 `Offset`/`LayoutOrder`
|
nativeMove (target, fromOffset, elements, toOffset) -- 범위 이동(사이가 밀림)
|
||||||
부기가 담당하니 안 넘긴다"였는데, 사용자 지적 — *"웹에서는 어떻게 되냐가
|
nativeSwap (target, offsetA, elementsA, offsetB, elementsB) -- 두 구간 맞교환(사이 고정)
|
||||||
모호함. 어디 둘지 어떻게 아느냐는것"* — 대로 **DOM류 백엔드는 삽입 위치를
|
nativeDispose(element) -- 트리 **밖** 값 파괴
|
||||||
알아야 하고, 정작 plain 요소는 `setOffsetSource`에 `None`을 등록해 그 숫자가
|
```
|
||||||
계산조차 안 되고 있었다.** 지금은 `Dispatch.getOffsetAt(ownerKey, i)`가 발행
|
|
||||||
채널 유무와 무관하게 그 숫자를 준다(`base/dispatch-core-plan.md`의
|
- **`offset`은 전부 0-based 절대 offset**(`Dispatch.getOffsetAt`이 주는 그 값).
|
||||||
"Length/Offset" 절). Roblox 백엔드는 이 인자를 그냥 무시한다 — `LayoutOrder`가
|
Roblox 백엔드는 이 인자를 그냥 무시한다 — `LayoutOrder`가 물리 순서와
|
||||||
물리 순서와 분리돼 있으므로.
|
분리돼 있으므로.
|
||||||
- **`unmountInst(element)`는 그대로 인자 없음** — 뺄 때는 자기 자신만 알면 된다.
|
- **⭐ 빠지는 요소는 반드시 `elements` 배열로 넘긴다** — `(target, offset, count)`만으로
|
||||||
- **0-based인 이유**: `offset`/`sum`이 이미 0-based 개수라 그쪽과 일관되고,
|
대상을 찾을 수 있는 건 DOM뿐이다(`childNodes[offset]`). **Roblox는 자식이 순서
|
||||||
`updateFn`의 `index`(1-based 지역 위치)와 헷갈리지 않게 하기 위함.
|
없는 집합**이고 quad의 offset은 순전히 논리값이라, 백엔드가 offset으로 인스턴스를
|
||||||
- **⚠️ 이름은 `disposeInst` 선례를 따른 가칭** — 주입 op 목록에 정식으로
|
역으로 못 찾는다. `count`는 `#elements`로 따라온다.
|
||||||
추가할 때 확정할 것(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와
|
- **`Replace`는 별도 op이 아니다** — `newElements`가 있는 `nativeRemove`(파괴 교체)
|
||||||
주입되는 엔진 op" 절).
|
또는 `nativeExtract`(비파괴 교체)다. `Splice`도 이 둘로 표현된다. 제거와 삽입을
|
||||||
|
한 호출로 합치는 이유는 **리플로우 2회와 그 사이 인덱스가 어긋난 창**을 없애기
|
||||||
|
위함(사용자: *"안 그러면 splice 가 무거워짐"*).
|
||||||
|
- **파괴/비파괴를 불리언이 아니라 이름으로 가른 이유**: 공개 CRUD의
|
||||||
|
`Remove` ↔ `Extract` 어휘를 그대로 물려받고, **백엔드의 융합**을 열어주기
|
||||||
|
위해서다 — Roblox에서 `Parent = nil` 후 `Destroy()`는 그냥 `Destroy()`보다
|
||||||
|
비싸므로(사용자 지적), `nativeRemove`가 그 자리에서 바로 파괴할 수 있어야 한다.
|
||||||
|
- **`nativeSwap`이 따로 있는 이유**: `Move`는 사이 요소를 전부 밀지만 `Swap`은
|
||||||
|
**가운데를 고정한 채 양끝만 교환**이라 다른 연산이다. `Move` 2회로 흉내내면
|
||||||
|
리플로우 2회 + 중간 인덱스 재계산이 필요하다.
|
||||||
|
- **`nativeInsert`를 흡수하지 않은 이유**: `nativeExtract(target, offset, {}, elements)`로
|
||||||
|
표현은 되지만, **최빈 경로**(리스트 최초 채우기·단건 `Add`)가 "0개를 빼는 extract"라는
|
||||||
|
모양이 되고 `DocumentFragment`류 일괄 삽입 최적화도 그 안에 숨는다.
|
||||||
|
- **기본 구현(조합 폴백) — 미주입이 에러가 아니다.** `addTag`/`setAttribute`가
|
||||||
|
"미주입이면 명확한 에러"인 것과 갈린다: 이쪽은 조합으로 항상 정의되기 때문이다.
|
||||||
|
`nativeRemove` = `nativeExtract` + `nativeDispose` 반복, `nativeMove` =
|
||||||
|
`nativeExtract` + `nativeInsert`, `nativeSwap` = `nativeMove` 2회. 백엔드는
|
||||||
|
**이득 있는 것만 덮어쓴다.**
|
||||||
|
- **⚠️ 전제 — 한 Slot의 물리 자식은 부모 안에서 연속 구간을 차지한다.** 범위 op이
|
||||||
|
성립하는 근거가 전부 이것이다(offset이 누적합이고 중첩 Slot도 같은
|
||||||
|
`physicalTarget`을 공유하므로 구조적으로 참). quad 밖에서 그 부모에 자식을 끼워
|
||||||
|
넣는 게 UB인 진짜 이유이기도 하다(`base/dispatch-core-plan.md`의 Length/Offset 절 끝, "동적 자식 추가/제거의
|
||||||
|
유일한 정당 경로" 문단).
|
||||||
|
- **⚠️ 이름은 여전히 가칭** — 주입 op 목록에 정식 등재할 때 확정할 것
|
||||||
|
(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절).
|
||||||
|
|
||||||
**추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미
|
**추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미
|
||||||
만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예:
|
만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예:
|
||||||
|
|
@ -1396,7 +1417,7 @@ function activateList(self, physicalTarget)
|
||||||
-- [2026-08-21 5라운드 `DE-13`] `_owned == false` 분기가 여기 있었으나
|
-- [2026-08-21 5라운드 `DE-13`] `_owned == false` 분기가 여기 있었으나
|
||||||
-- 제거했다 — unowned Slot은 `_detached`를 **애초에 채우지 않으므로**
|
-- 제거했다 — unowned Slot은 `_detached`를 **애초에 채우지 않으므로**
|
||||||
-- (위 `settle`의 detach 분기) 여기 오는 건 전부 내 것이다.
|
-- (위 `settle`의 detach 분기) 여기 오는 건 전부 내 것이다.
|
||||||
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
|
if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end
|
||||||
self._detached[key] = nil
|
self._detached[key] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -2082,7 +2103,7 @@ local function mountSlotTree(slot, physicalTarget)
|
||||||
mountSlotTree(element, physicalTarget) -- 자식은 자기 Offset에서 다시 시작
|
mountSlotTree(element, physicalTarget) -- 자식은 자기 Offset에서 다시 시작
|
||||||
acc += element.Length:Get()
|
acc += element.Length:Get()
|
||||||
else
|
else
|
||||||
mountInst(physicalTarget, element, acc) -- 주입 op(위 "물리 조작은 주입 op" 절)
|
nativeInsert(physicalTarget, acc, { element }) -- 주입 op(위 "native*" 절)
|
||||||
acc += 1
|
acc += 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -2180,7 +2201,7 @@ local function unmountSlotTree(slot)
|
||||||
if isSlot(element) then
|
if isSlot(element) then
|
||||||
unmountSlotTree(element) -- 재귀 — 중첩 Slot도 똑같이 비파괴
|
unmountSlotTree(element) -- 재귀 — 중첩 Slot도 똑같이 비파괴
|
||||||
else
|
else
|
||||||
unmountInst(element) -- 파괴 아님(주입 op — 위 "물리 조작은 주입 op" 절)
|
nativeExtract(physicalTarget, Dispatch.getOffsetAt(slot, i), { element }) -- 파괴 아님(주입 op)
|
||||||
end
|
end
|
||||||
-- releaseOwner를 **안 부름** — 자식들은 여전히 이 slot의 소유. 이게
|
-- releaseOwner를 **안 부름** — 자식들은 여전히 이 slot의 소유. 이게
|
||||||
-- destroySlotTree와의 핵심 차이(파괴는 소유권까지 반납, 언마운트는 유지).
|
-- destroySlotTree와의 핵심 차이(파괴는 소유권까지 반납, 언마운트는 유지).
|
||||||
|
|
@ -2226,7 +2247,7 @@ local function destroySlotTree(slot)
|
||||||
if isSlot(element) then
|
if isSlot(element) then
|
||||||
destroySlotTree(element) -- 재귀는 "파괴"에만, choreography 없음
|
destroySlotTree(element) -- 재귀는 "파괴"에만, choreography 없음
|
||||||
else
|
else
|
||||||
disposeInst(element)
|
nativeDispose(element)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- [2026-08-21] Detach로 홀드 중인 요소도 같이 파괴 — 이것들은 `_elements`에
|
-- [2026-08-21] Detach로 홀드 중인 요소도 같이 파괴 — 이것들은 `_elements`에
|
||||||
|
|
@ -2234,7 +2255,7 @@ local function destroySlotTree(slot)
|
||||||
-- 잘 죽이는가**의 답이 정확히 이 줄이다(아래 "`dispose`" 절).
|
-- 잘 죽이는가**의 답이 정확히 이 줄이다(아래 "`dispose`" 절).
|
||||||
if slot._detached then -- [2026-08-21 5라운드 `DE-7`] lazy — 없으면 통째로 스킵
|
if slot._detached then -- [2026-08-21 5라운드 `DE-7`] lazy — 없으면 통째로 스킵
|
||||||
for key, element in pairs(slot._detached) do
|
for key, element in pairs(slot._detached) do
|
||||||
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
|
if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end
|
||||||
slot._detached[key] = nil
|
slot._detached[key] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -2300,7 +2321,8 @@ function rawUnmount(self, index)
|
||||||
unbindLifetime(bk.observers[index])
|
unbindLifetime(bk.observers[index])
|
||||||
end
|
end
|
||||||
releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음)
|
releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음)
|
||||||
if isSlot(element) then unmountSlotTree(element) else unmountInst(element) end
|
if isSlot(element) then unmountSlotTree(element)
|
||||||
|
else nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end
|
||||||
|
|
||||||
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
|
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
|
||||||
recompute(self, bk)
|
recompute(self, bk)
|
||||||
|
|
@ -2317,7 +2339,8 @@ function rawDetach(self, index)
|
||||||
unbindLifetime(bk.observers[index])
|
unbindLifetime(bk.observers[index])
|
||||||
end
|
end
|
||||||
-- releaseOwner를 **안 부름** — 이게 rawUnmount와의 유일한 차이
|
-- releaseOwner를 **안 부름** — 이게 rawUnmount와의 유일한 차이
|
||||||
if isSlot(element) then unmountSlotTree(element) else unmountInst(element) end
|
if isSlot(element) then unmountSlotTree(element)
|
||||||
|
else nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end
|
||||||
|
|
||||||
spliceArraysDown(self, index)
|
spliceArraysDown(self, index)
|
||||||
recompute(self, bk)
|
recompute(self, bk)
|
||||||
|
|
@ -2331,7 +2354,7 @@ end
|
||||||
function releaseElement(self, index, element, wasDetached)
|
function releaseElement(self, index, element, wasDetached)
|
||||||
if wasDetached then
|
if wasDetached then
|
||||||
if self._owned ~= false then
|
if self._owned ~= false then
|
||||||
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
|
if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end
|
||||||
end
|
end
|
||||||
return -- 이미 물리 트리 밖 + 부기 밖이라 더 할 일 없음
|
return -- 이미 물리 트리 밖 + 부기 밖이라 더 할 일 없음
|
||||||
end
|
end
|
||||||
|
|
@ -2367,11 +2390,15 @@ function rawAdd(self, element, index, fromDetached)
|
||||||
if isSlot(element) then
|
if isSlot(element) then
|
||||||
attachSlot(element, self._mountedInst, self, index) -- 자식이 자기 부기+물리를 다 함
|
attachSlot(element, self._mountedInst, self, index) -- 자식이 자기 부기+물리를 다 함
|
||||||
else
|
else
|
||||||
Dispatch.setOffsetSource(self, index, None) -- 순서는 늘 offsetSource → setLength(C4)
|
-- [순서 재정렬, 2026-08-21] **자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중.**
|
||||||
Dispatch.setLength(self, index, 1, self._mountedInst)
|
-- 옛 "부기 전부 먼저"는 base가 물리적으로 자리를 비워둘 수 있다는 전제였는데
|
||||||
recompute(self, bk) -- 부기 완결(C7: 물리보다 먼저)
|
-- 그런 수단이 없다 — 미는 주체는 `nativeInsert` 자신이다(아래 `dispatch-core-plan.md`
|
||||||
mountInst(self._mountedInst, element, Dispatch.getOffsetAt(self, index))
|
-- "물리와 부기의 순서" 절).
|
||||||
-- 그 다음에야 물리 마운트(주입 op, 위 그 절)
|
Dispatch.setOffsetSource(self, index, None) -- 내 자리 offset은 1..index-1의 합이라
|
||||||
|
-- 내 삽입으로 안 변한다 → 먼저 해도 안전
|
||||||
|
nativeInsert(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
|
||||||
|
Dispatch.setLength(self, index, 1, self._mountedInst) -- **뒤를 미는 것**은 그 다음
|
||||||
|
recompute(self, bk)
|
||||||
end
|
end
|
||||||
return index
|
return index
|
||||||
end
|
end
|
||||||
|
|
@ -2387,27 +2414,40 @@ function rawReplace(self, index, newElement, destroyOld)
|
||||||
local oldElement = self._elements[index]
|
local oldElement = self._elements[index]
|
||||||
claimOwner(newElement, self) -- 새 요소 먼저 클레임(실패하면 아무것도 안 바뀜)
|
claimOwner(newElement, self) -- 새 요소 먼저 클레임(실패하면 아무것도 안 바뀜)
|
||||||
releaseOwner(oldElement, self)
|
releaseOwner(oldElement, self)
|
||||||
|
|
||||||
if self._mounted then
|
|
||||||
-- 빼기는 물리 먼저(C7의 "좁은 쪽이 먼저")
|
|
||||||
if isSlot(oldElement) then unmountSlotTree(oldElement) else unmountInst(oldElement) end
|
|
||||||
end
|
|
||||||
if destroyOld then
|
|
||||||
if isSlot(oldElement) then destroySlotTree(oldElement) else disposeInst(oldElement) end
|
|
||||||
end
|
|
||||||
|
|
||||||
self._elements[index] = newElement -- **시프트 없음** — 자리 수가 안 변한다
|
self._elements[index] = newElement -- **시프트 없음** — 자리 수가 안 변한다
|
||||||
if not self._mounted then return end
|
|
||||||
|
if not self._mounted then
|
||||||
|
if destroyOld then -- 트리 밖이라 물리 op이 필요 없다
|
||||||
|
if isSlot(oldElement) then destroySlotTree(oldElement) else nativeDispose(oldElement) end
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local bk = getBookkeeping(self)
|
local bk = getBookkeeping(self)
|
||||||
if isSlot(newElement) then
|
local offset = Dispatch.getOffsetAt(self, index)
|
||||||
attachSlot(newElement, self._mountedInst, self, index)
|
|
||||||
else
|
-- [2026-08-21] **원자적 교체** — 빼기와 넣기를 한 호출로 합친다(리플로우 1회,
|
||||||
|
-- 그 사이 인덱스가 어긋난 창이 없음). 파괴 여부는 op **이름**이 가른다.
|
||||||
|
if isSlot(oldElement) or isSlot(newElement) then
|
||||||
|
-- 어느 한쪽이 Slot이면 구간 길이가 1이 아니라 각자의 경로로 간다
|
||||||
|
if isSlot(oldElement) then
|
||||||
|
if destroyOld then destroySlotTree(oldElement) else unmountSlotTree(oldElement) end
|
||||||
|
else
|
||||||
|
local op = if destroyOld then nativeRemove else nativeExtract
|
||||||
|
op(self._mountedInst, offset, { oldElement })
|
||||||
|
end
|
||||||
|
if isSlot(newElement) then
|
||||||
|
attachSlot(newElement, self._mountedInst, self, index)
|
||||||
|
return
|
||||||
|
end
|
||||||
Dispatch.setOffsetSource(self, index, None)
|
Dispatch.setOffsetSource(self, index, None)
|
||||||
Dispatch.setLength(self, index, 1, self._mountedInst)
|
nativeInsert(self._mountedInst, offset, { newElement })
|
||||||
recompute(self, bk)
|
else
|
||||||
mountInst(self._mountedInst, newElement, Dispatch.getOffsetAt(self, index))
|
local op = if destroyOld then nativeRemove else nativeExtract
|
||||||
|
op(self._mountedInst, offset, { oldElement }, { newElement }) -- 한 번에
|
||||||
end
|
end
|
||||||
|
Dispatch.setLength(self, index, 1, self._mountedInst)
|
||||||
|
recompute(self, bk)
|
||||||
end
|
end
|
||||||
|
|
||||||
function rawRemove(self, index)
|
function rawRemove(self, index)
|
||||||
|
|
@ -2421,7 +2461,10 @@ function rawRemove(self, index)
|
||||||
-- rawExtract가 releaseOwner를 부른다고 이미 명시하고
|
-- rawExtract가 releaseOwner를 부른다고 이미 명시하고
|
||||||
-- 있었는데 코드만 불일치) — 엄격 releaseOwner가
|
-- 있었는데 코드만 불일치) — 엄격 releaseOwner가
|
||||||
-- 들어온 뒤로는 이 누락이 실동작 차이를 만듦
|
-- 들어온 뒤로는 이 누락이 실동작 차이를 만듦
|
||||||
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
|
-- [2026-08-21] 파괴 경로는 **한 번에** — 빼기와 파괴를 백엔드가 융합할 수 있다
|
||||||
|
-- (Roblox는 Parent=nil 없이 그 자리에서 Destroy가 더 싸다).
|
||||||
|
if isSlot(element) then destroySlotTree(element)
|
||||||
|
else nativeRemove(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end
|
||||||
|
|
||||||
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
|
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
|
||||||
recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만
|
recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만
|
||||||
|
|
@ -2824,7 +2867,7 @@ function dispose(value)
|
||||||
-- 위 elementOwner 기반 판정 재사용 — 요구 중이면 error, 아니면 재귀 파괴
|
-- 위 elementOwner 기반 판정 재사용 — 요구 중이면 error, 아니면 재귀 파괴
|
||||||
...
|
...
|
||||||
else
|
else
|
||||||
disposeInst(value) -- 아래 주입 op
|
nativeDispose(value) -- 아래 주입 op
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
@ -2858,10 +2901,10 @@ end
|
||||||
막는 것이고, `unbindLifetime`은 Observer/Effect류의 GC 앵커를 조기
|
막는 것이고, `unbindLifetime`은 Observer/Effect류의 GC 앵커를 조기
|
||||||
해제하는 것 — 축이 달라 서로 대체 불가.
|
해제하는 것 — 축이 달라 서로 대체 불가.
|
||||||
|
|
||||||
**base/backend 분리 — `disposeInst`는 주입 op**(`base/dispatch-core-plan.md`
|
**base/backend 분리 — `nativeDispose`는 주입 op**(`base/dispatch-core-plan.md`
|
||||||
"base가 소유하는 핸들러와 주입되는 엔진 op" 절과 같은 패턴, `addTag`/
|
"base가 소유하는 핸들러와 주입되는 엔진 op" 절과 같은 패턴, `addTag`/
|
||||||
`removeTag`/`setAttribute`가 선례): `dispose`가 `isSlot`이 아닌 값을
|
`removeTag`/`setAttribute`가 선례): `dispose`가 `isSlot`이 아닌 값을
|
||||||
받으면 base가 시그니처만 소유하는 `disposeInst(inst: any): ()`로 위임 —
|
받으면 base가 시그니처만 소유하는 `nativeDispose(inst: any): ()`로 위임 —
|
||||||
quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 방식으로
|
quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 방식으로
|
||||||
매핑.
|
매핑.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -797,6 +797,82 @@ index 를 찾아야하던걸로 앎."*
|
||||||
자체가 뒤를 밀고 당긴다. 단 **백엔드 op이 아토믹한 최소 단위**여야 한다는 게
|
자체가 뒤를 밀고 당긴다. 단 **백엔드 op이 아토믹한 최소 단위**여야 한다는 게
|
||||||
같이 확인된 요구사항.
|
같이 확인된 요구사항.
|
||||||
- **`B-2`(`getOffsetAt` O(N²)) → 접두합 캐시로.** `bk.offsetCache` +
|
- **`B-2`(`getOffsetAt` O(N²)) → 접두합 캐시로.** `bk.offsetCache` +
|
||||||
`bk.offsetDirtyFrom`("이 인덱스 초과부터 무효"), 무효화 트리거 셋을 명시
|
**`bk.invalidAfter`**("여기까지는 유효"). **[같은 자리에서 사용자가 의사코드로
|
||||||
(`setLength`는 `i+1`부터 / splice는 그 자리부터 / 베이스 변경은 전체).
|
정정]** 함수를 둘로 나누지 않고 **단일 `getOffsetAt`이 필요한 만큼만 이어붙인다**
|
||||||
**남은 작은 확인**: `recompute`의 전체 순회가 그 캐시를 같이 채우게 할지.
|
— 순차 호출이면 한 칸씩 늘어나 전체 O(N)이고, `recompute`도 그 위에 얹힌다.
|
||||||
|
무효화 규칙도 하나로 줄었다: `invalidAfter = min(invalidAfter, i)`
|
||||||
|
(`setLength`도 splice도 **자기 인덱스까지**만 당긴다 — `i` 자리의 offset은
|
||||||
|
`1..i-1`의 합이라 안 바뀌므로. 베이스 변경만 `0`).
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# L. `native*` 물리 조작 계층 확정 (2026-08-21, 대화 마지막 라운드)
|
||||||
|
|
||||||
|
`mountInst`/`unmountInst`/`disposeInst` 셋으로는 **`Move`/`Swap`을 아예 표현할
|
||||||
|
수 없다**는 지적에서 시작해, 물리 조작 계층 전체가 재설계됐다.
|
||||||
|
|
||||||
|
## L-1. 층위 정의 (사용자)
|
||||||
|
|
||||||
|
> **`raw*`는 그 Slot 스코프 안의 연산**(평탄화 전, `_elements` 인덱스),
|
||||||
|
> **`native*`는 확정된 offset/length로 표현되는 물리 트리 연산**(평탄화 후, 절대 좌표).
|
||||||
|
|
||||||
|
## L-2. 확정된 표면
|
||||||
|
|
||||||
|
```lua
|
||||||
|
nativeInsert (target, offset, elements) -- 삽입(자신이 밀어냄)
|
||||||
|
nativeExtract(target, offset, elements, newElements?) -- 빼되 살림 (+교체 삽입)
|
||||||
|
nativeRemove (target, offset, elements, newElements?) -- 빼면서 파괴 (+교체 삽입)
|
||||||
|
nativeMove (target, fromOffset, elements, toOffset)
|
||||||
|
nativeSwap (target, offsetA, elementsA, offsetB, elementsB)
|
||||||
|
nativeDispose(element) -- 트리 밖 값 파괴
|
||||||
|
```
|
||||||
|
|
||||||
|
정해진 것들:
|
||||||
|
|
||||||
|
- **`Replace`는 별도 op이 아니다** — `newElements`가 있는 `Remove`/`Extract`.
|
||||||
|
`Splice`도 이 둘로 표현된다(사용자: *"count 만큼 공간을 축소 newElements
|
||||||
|
길이만큼 공간을 확장할 수 있게 한번에 처리. 안 그러면 splice 가 무거워짐"*).
|
||||||
|
- **파괴/비파괴를 불리언이 아니라 이름으로 가른다** — 공개 CRUD의
|
||||||
|
`Remove`↔`Extract` 어휘를 물려받고, **백엔드 융합**을 연다(Roblox에서
|
||||||
|
`Parent = nil` 후 `Destroy()`가 그냥 `Destroy()`보다 비싸다는 사용자 지적).
|
||||||
|
- **⭐ 빠지는 요소는 반드시 배열로 넘긴다** — 에이전트가 "count면 충분하지
|
||||||
|
않나"는 질문에 답하다 확인: `(target, offset, count)`로 대상을 찾을 수 있는 건
|
||||||
|
**DOM뿐**(`childNodes[offset]`)이고, **Roblox는 자식이 순서 없는 집합**이라
|
||||||
|
offset으로 인스턴스를 역으로 못 찾는다.
|
||||||
|
- **`nativeSwap`은 별도** — `Move`는 사이를 전부 밀지만 `Swap`은 가운데를 고정한
|
||||||
|
채 양끝만 교환이라 다른 연산(사용자 지적).
|
||||||
|
- **`nativeInsert`는 흡수하지 않는다** — 최빈 경로가 "0개를 빼는 extract"가 되는
|
||||||
|
모양을 피하고 일괄 삽입 최적화를 그 안에 숨기지 않기 위해.
|
||||||
|
- **미주입이면 에러가 아니라 조합 폴백** — `addTag` 계열과 갈리는 지점.
|
||||||
|
- **⚠️ 전제**: 한 Slot의 물리 자식은 부모 안에서 **연속 구간**을 차지한다(범위
|
||||||
|
op이 성립하는 근거 전부).
|
||||||
|
|
||||||
|
## L-3. ⭐ 그 결과 `C-7` 일반 계약이 역전됐다
|
||||||
|
|
||||||
|
*"부기가 물리보다 항상 먼저"*는 **"`Length`를 먼저 올려 밀어내고 그 공간에
|
||||||
|
넣는다"**는 그림 위에 서 있었는데, **base에는 물리적으로 자리를 비워둘 수단이
|
||||||
|
없다**(사용자: *"밀어내고 null을 넣어둘 수도 없는데, nativeInsert 라는것 자체가
|
||||||
|
밀어내기 동작을 강제"*). 미는 주체는 언제나 백엔드의 삽입 연산 자신이다.
|
||||||
|
|
||||||
|
**새 계약** — 순서 규칙이 "두 얼굴"에서 하나로 줄었다:
|
||||||
|
|
||||||
|
> **자기 자리를 정하는 것(`setOffsetSource`)은 먼저 / 뒤를 미는 것
|
||||||
|
> (`setLength` → `recompute`)은 나중.**
|
||||||
|
|
||||||
|
`setOffsetSource`가 먼저여야 하는 이유는 그 자리의 offset이 `1..i-1`의 합이라
|
||||||
|
**자기 삽입으로 안 변하고**, 삽입 위치 계산과 `activateList`(C1)가 그 값을 쓰기
|
||||||
|
때문이다. 그래서 `rawAdd`는 `spliceArraysUp` → `setOffsetSource` →
|
||||||
|
`nativeInsert` → `setLength` → `recompute`.
|
||||||
|
|
||||||
|
**배치 경로(`materializeSlotTree` → `mountSlotTree`)는 그대로 부기 전량이
|
||||||
|
먼저** — 그건 C6가 요구하는 별개 사안이고 새 규칙과 모순되지 않는다.
|
||||||
|
|
||||||
|
역전 원문은 `archive/bookkeeping-before-physical-reversed.md`.
|
||||||
|
|
||||||
|
## L-4. 반영 파일
|
||||||
|
|
||||||
|
`slot-plan.md`(op 절 전면 재작성, `rawAdd`/`rawReplace`/`rawRemove`/`rawUnmount`/
|
||||||
|
`rawDetach`/`mountSlotTree`/`unmountSlotTree` 의사코드), `dispatch-core-plan.md`
|
||||||
|
(C-7 재정의 + 주입 op 목록), `architecture.md`(`EngineOps.luau`), `ROADMAP.md`(M5
|
||||||
|
주입 표면), `archive/`(역전 원문), `README.md` 색인.
|
||||||
|
|
|
||||||
|
|
@ -192,11 +192,12 @@
|
||||||
**사용자 제안 채택**: *"getOffsetAt 은 compute 된걸 캐시해도 될듯. length
|
**사용자 제안 채택**: *"getOffsetAt 은 compute 된걸 캐시해도 될듯. length
|
||||||
변경되는 뒤는 캐시가 무효화되도록 shouldRecomputeAfter 등을 둬서 특정 인덱스
|
변경되는 뒤는 캐시가 무효화되도록 shouldRecomputeAfter 등을 둬서 특정 인덱스
|
||||||
초과부는 offset 다시 계산하고, 해당 값 위치 자체는 유효하므로 그것을 통해 더
|
초과부는 offset 다시 계산하고, 해당 값 위치 자체는 유효하므로 그것을 통해 더
|
||||||
length 를 이어붙이면 될듯."* → `bk.offsetCache` + `bk.offsetDirtyFrom`으로
|
length 를 이어붙이면 될듯."* → `bk.offsetCache` + **`bk.invalidAfter`**("여기까지는 유효")로 반영
|
||||||
반영(`base/dispatch-core-plan.md`). 무효화 트리거 셋(`setLength`는 `i+1`부터,
|
(`base/dispatch-core-plan.md`). **무효화 규칙은 하나** —
|
||||||
splice는 그 자리부터, 베이스 변경은 전체)까지 명시. **남은 작은 확인 하나** —
|
`invalidAfter = min(invalidAfter, i)`(`setLength`도 splice도 자기 인덱스까지,
|
||||||
`recompute`의 전체 순회가 그 캐시를 같이 채우게 할지는 구현 시 판단(그 문서에
|
베이스 변경만 `0`). `recompute`도 이 캐시 위에 얹혀 O(N)이라
|
||||||
⚠️로 표시). 아래는 열려 있던 시점의 서술:
|
**"캐시를 누가 채우나"라는 갈래가 없어졌다**(사용자 정정: 함수를 나눌 이유가
|
||||||
|
없다). 아래는 열려 있던 시점의 서술:
|
||||||
`settle`이 키마다 `rawAdd`/`rawReplace`를 부르고 그 각각이 `getOffsetAt`(O(i))을
|
`settle`이 키마다 `rawAdd`/`rawReplace`를 부르고 그 각각이 `getOffsetAt`(O(i))을
|
||||||
부르므로, 이미 마운트된 리스트의 데이터가 통째로 바뀌면 **O(N²)**다(최초 마운트는
|
부르므로, 이미 마운트된 리스트의 데이터가 통째로 바뀌면 **O(N²)**다(최초 마운트는
|
||||||
`_mounted == false`라 얼리 리턴이 막아준다). `DC-9`에서 `setOffsetSource`의 즉시
|
`_mounted == false`라 얼리 리턴이 막아준다). `DC-9`에서 `setOffsetSource`의 즉시
|
||||||
|
|
|
||||||
|
|
@ -1683,5 +1683,8 @@ lazy화, `KeyGone`엔 새 값 반환도 error, **`Owned=false`에서 `Detach`는
|
||||||
역전 배너 없이 자기모순이던 것, 그리고 **손대지 않은 문서**(`ROADMAP` 백로그
|
역전 배너 없이 자기모순이던 것, 그리고 **손대지 않은 문서**(`ROADMAP` 백로그
|
||||||
문단·`debounce-throttle-plan.md`)가 "Gate는 M3에서"로 남아 있던 사각지대.
|
문단·`debounce-throttle-plan.md`)가 "Gate는 M3에서"로 남아 있던 사각지대.
|
||||||
감사 회신 자리에서 **`raw*`의 index 통일**(오래 열린 캐비엇 종결), **래핑은
|
감사 회신 자리에서 **`raw*`의 index 통일**(오래 열린 캐비엇 종결), **래핑은
|
||||||
`raw*` 바깥**, **`getOffsetAt` 접두합 캐시**까지 확정. **`Gate`만 사용자 지시로
|
`raw*` 바깥**, **`getOffsetAt` 접두합 캐시**까지 확정. 마지막 라운드에선 **`native*` 물리 조작 계층**(Slot 스코프의 `raw*`와 갈리는,
|
||||||
다음 세션 — M2를 막는 유일한 항목.**
|
확정된 offset/length 기반 여섯 op)이 확정되고 그 여파로 **4라운드 `C-7`("부기가
|
||||||
|
물리보다 먼저")이 역전**됐다 — base엔 자리를 비워둘 수단이 없고 미는 주체는
|
||||||
|
백엔드 삽입 연산 자신이라, 규칙이 "자기 자리 먼저 / 뒤를 미는 것 나중" 하나로
|
||||||
|
줄었다. **`Gate`만 사용자 지시로 다음 세션 — M2를 막는 유일한 항목.**
|
||||||
|
|
|
||||||
|
|
@ -176,9 +176,29 @@
|
||||||
|
|
||||||
**그리고 감사 회신 자리에서 결정 셋이 더 확정됐다** — `raw*`를 **index로 통일**
|
**그리고 감사 회신 자리에서 결정 셋이 더 확정됐다** — `raw*`를 **index로 통일**
|
||||||
(오래 열려 있던 캐비엇 종결), **래핑은 `raw*` 바깥**, 그리고 `getOffsetAt`의
|
(오래 열려 있던 캐비엇 종결), **래핑은 `raw*` 바깥**, 그리고 `getOffsetAt`의
|
||||||
**접두합 캐시**(`offsetDirtyFrom`). offset 물리 재배치 질문도 "DOM은 insert가
|
**접두합 캐시**(`invalidAfter` — 단일 함수가 필요한 만큼만 이어붙임). offset 물리 재배치 질문도 "DOM은 insert가
|
||||||
알아서 밀어낸다"로 닫혔다.
|
알아서 밀어낸다"로 닫혔다.
|
||||||
|
|
||||||
|
## 6-3. 마지막 라운드 — `native*` 계층 확정, `C-7` 역전
|
||||||
|
|
||||||
|
주입 op 셋(`mountInst`/`unmountInst`/`disposeInst`)으로는 **`Move`/`Swap`을 아예
|
||||||
|
표현할 수 없다**는 사용자 지적에서 시작해 물리 조작 계층이 재설계됐다.
|
||||||
|
|
||||||
|
- **층위 정의가 생겼다** — `raw*` = Slot 스코프(평탄화 전), `native*` = 확정된
|
||||||
|
offset/length 기반 물리 연산(평탄화 후).
|
||||||
|
- 표면은 여섯(`nativeInsert`/`Extract`/`Remove`/`Move`/`Swap`/`Dispose`).
|
||||||
|
`Replace`는 별도 op이 아니라 **`newElements`가 있는 Remove/Extract**이고,
|
||||||
|
파괴/비파괴는 **불리언이 아니라 이름**으로 가른다(Roblox의 "그 자리에서 바로
|
||||||
|
Destroy" 융합을 열기 위해).
|
||||||
|
- **⭐ 대상 요소를 배열로 넘겨야 한다** — `(target, offset, count)`로 찾을 수
|
||||||
|
있는 건 DOM뿐이고 **Roblox는 자식이 순서 없는 집합**이라 offset 역조회가 안 된다.
|
||||||
|
- **⭐ `C-7`("부기가 물리보다 항상 먼저")이 역전됐다** — base엔 물리적으로 자리를
|
||||||
|
비워둘 수단이 없고 미는 주체는 백엔드의 삽입 연산 자신이다. 규칙이
|
||||||
|
**"자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중"** 하나로 줄었다.
|
||||||
|
원문은 `archive/bookkeeping-before-physical-reversed.md`.
|
||||||
|
- 같은 라운드에서 `getOffsetAt`의 접두합 캐시도 사용자 의사코드로 정정 —
|
||||||
|
**단일 함수 + `invalidAfter`**, 무효화는 `min(invalidAfter, i)` 하나.
|
||||||
|
|
||||||
## 7. 남긴 파일
|
## 7. 남긴 파일
|
||||||
|
|
||||||
- `qa-request/pre-implementation-qa-round5.md` — 문항지(205문항)
|
- `qa-request/pre-implementation-qa-round5.md` — 문항지(205문항)
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,9 @@
|
||||||
**아직 회신 대기인 것은 그 followup의 C절**(`rawAdd` 의사코드 승인,
|
**아직 회신 대기인 것은 그 followup의 C절**(`rawAdd` 의사코드 승인,
|
||||||
`rawAdd`의 `Length:Set` 제거, `updateFn`이 State를 반환할 때의 래핑/`prev`,
|
`rawAdd`의 `Length:Set` 제거, `updateFn`이 State를 반환할 때의 래핑/`prev`,
|
||||||
`setLength` 앵커를 물리 target으로 되돌리기, `Gate` 이름·표면, `Effect`
|
`setLength` 앵커를 물리 target으로 되돌리기, `Gate` 이름·표면, `Effect`
|
||||||
다중 의존성) — **[2026-08-21 2차 회신으로 전량 처리 완료]**, 소스는 그
|
다중 의존성) — **[2026-08-21 전량 처리 완료]**, 소스는 그 파일의 **마지막
|
||||||
파일의 **F절**. 그중 **`Gate`(공용 게이트 노드) 설계만 사용자 지시로 다음
|
절**(A~L). 대화 마지막 라운드에서 **`native*` 물리 조작 계층**이 확정되며
|
||||||
|
4라운드 `C-7`("부기가 물리보다 먼저")이 역전됐다. 그중 **`Gate`(공용 게이트 노드) 설계만 사용자 지시로 다음
|
||||||
세션으로 미뤄졌다**(*"고칠것이 많으므로 Gate 는 다음 세션에 다루겠음 …
|
세션으로 미뤄졌다**(*"고칠것이 많으므로 Gate 는 다음 세션에 다루겠음 …
|
||||||
지금 세션 상 지식만 이전될 수 있게 두세요"*) — 재료는
|
지금 세션 상 지식만 이전될 수 있게 두세요"*) — 재료는
|
||||||
`research/gate-primitive.md`에 모아뒀고 **M2 착수를 막는 유일한 항목**이다.
|
`research/gate-primitive.md`에 모아뒀고 **M2 착수를 막는 유일한 항목**이다.
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
||||||
|
|
||||||
## M5 — quad-roblox 최소 프로바이더
|
## M5 — quad-roblox 최소 프로바이더
|
||||||
|
|
||||||
|
> **[2026-08-21 5라운드] 주입 표면이 늘었다 — `native*` 물리 트리 조작 계층.**
|
||||||
|
> `nativeInsert`/`nativeExtract`/`nativeRemove`/`nativeMove`/`nativeSwap`/
|
||||||
|
> `nativeDispose`(이름 가칭). base가 `Parent`를 모른다는 원칙을 실제로 지키기
|
||||||
|
> 위한 것이고, **미주입이면 에러가 아니라 조합 폴백**이라 최소 구현 부담은
|
||||||
|
> `nativeInsert`/`nativeExtract`/`nativeDispose` 셋이다(나머지는 이득 있을 때만
|
||||||
|
> 덮어씀 — Roblox는 `nativeRemove`를 "그 자리에서 바로 `Destroy`"로 융합하는 게
|
||||||
|
> 실익). 상세는 `base/slot-plan.md`의 "물리 조작은 주입 op다" 절.
|
||||||
|
|
||||||
> **⚠️ 구현 관례**: `quad-roblox`의 공개 타입은 지금부터 단일 파일
|
> **⚠️ 구현 관례**: `quad-roblox`의 공개 타입은 지금부터 단일 파일
|
||||||
> (`src/init.luau` 또는 `types.luau`)에 몰아둘 것 — 나중에 필요해지면
|
> (`src/init.luau` 또는 `types.luau`)에 몰아둘 것 — 나중에 필요해지면
|
||||||
> 백로그 `quad-roblox-types`(가칭, `quad-types`와 같은 패턴)로 쉽게
|
> 백로그 `quad-roblox-types`(가칭, `quad-types`와 같은 패턴)로 쉽게
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue