Files
minecraft_launcher/src/installer-rp/pack.ts
claude-bot a8b9b689c2 resourcepack: gate shader strip on declared maxFmt, not build target
베이스팩의 vanilla 셰이더는 manifest 의 mcVersion(resolved.format) 이 64 이하
라도, 우리가 supported_formats/max_format 으로 1.21.9+ 까지 호환을 선언하면
새 GLSL API 환경에서 로드돼 "리소스 새로고침 실패" 가 다시 난다. 셰이더 제거
판정 기준을 resolved.format > 64 에서 maxFmt > 64 로 옮기고, 그 계산을
mcmeta 작성보다 먼저 수행한다. 로그의 format 값도 maxFmt 를 표시해 어떤
호환 상한 때문에 제거됐는지 추적 가능하게 했다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:49:11 +09:00

172 lines
7.3 KiB
TypeScript

import { promises as fs, createWriteStream } from 'node:fs'
import path from 'node:path'
import archiver from 'archiver'
import extract from 'extract-zip'
import { resolveResourcePackFormat, MIN_SUPPORTED_FORMAT, LATEST_KNOWN_FORMAT } from './packFormat.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
const NAMESPACE = 'musicquiz'
export interface BuildResourcepackOptions {
/** ogg 음악 파일들이 들어 있는 폴더 (01.ogg, 02.ogg, …). */
musicDir: string
/** cover_NN.png 파일들이 들어 있는 폴더. */
paintingDir: string
/** pack.mcmeta 의 description 에 들어갈 표시 이름. */
packName: string
/** /manifest/<key>.json 의 mcVersion. pack_format 결정용. */
mcVersion: string
/** 작업 폴더(임시). 이 안에 트리를 펼친 뒤 zip 생성. */
workDir: string
/** 최종 zip 출력 경로. */
outZipPath: string
/**
* 베이스 리소스팩 zip 경로 (선택). 지정하면 이 zip 의 내용을 먼저 풀고
* 그 위에 음악·사진·sounds.json·pack.mcmeta 를 덮어/병합한다.
*/
baseZipPath?: string
/** 진단용 로그 콜백 (선택). */
log?: (line: string) => void
}
/**
* 임시 폴더에 리소스팩 트리를 펼치고, archiver 로 zip 으로 묶어 outZipPath 에 저장.
*
* 트리 구조:
* pack.mcmeta
* assets/musicquiz/sounds.json
* assets/musicquiz/sounds/track_NN.ogg ← musicDir/NN.ogg 에서 옮김
* assets/musicquiz/textures/painting/cover_NN.png ← paintingDir/cover_NN.png 에서 옮김
*/
export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise<void> {
const root = path.join(opts.workDir, 'resourcepack')
// 베이스가 있건 없건 작업 트리는 항상 처음부터 다시 만든다.
await fs.rm(root, { recursive: true, force: true })
await fs.mkdir(root, { recursive: true })
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
if (opts.baseZipPath) {
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
await extract(opts.baseZipPath, { dir: root })
}
const soundsDir = path.join(root, 'assets', NAMESPACE, 'sounds')
const paintingOutDir = path.join(root, 'assets', NAMESPACE, 'textures', 'painting')
await fs.mkdir(soundsDir, { recursive: true })
await fs.mkdir(paintingOutDir, { recursive: true })
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
const resolved = resolveResourcePackFormat(opts.mcVersion)
if (resolved.matched) {
opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched }))
} else {
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
}
// 호환 범위는 1.21.6 (=MIN_SUPPORTED_FORMAT) 부터 알려진 최신까지 선언한다.
// 빌드 타깃이 LATEST_KNOWN_FORMAT 보다 높으면(테이블 갱신 전 신버전) 그 값까지 확장.
// (셰이더 제거 판정에도 maxFmt 를 쓰므로 mcmeta 작성보다 먼저 계산해 둔다.)
const minFmt = Math.min(MIN_SUPPORTED_FORMAT, resolved.format)
const maxFmt = Math.max(LATEST_KNOWN_FORMAT, resolved.format)
// 1-a) 선언 호환 범위의 max 가 64 를 넘으면(=1.21.9+ 클라이언트에서도 로드 가능)
// 구버전 베이스팩의 assets/minecraft/shaders/* 가 새 GLSL API 와 충돌해 컴파일에
// 실패한다. 결과적으로 "리소스 새로고침 실패" 가 다시 뜨므로, 이 경우엔 해당
// 디렉터리를 결과 zip 에서 제거한다. 텍스처/모델 등 나머지 자산은 그대로 유지.
if (opts.baseZipPath && maxFmt > 64) {
const vanillaShaderDir = path.join(root, 'assets', 'minecraft', 'shaders')
try {
const stat = await fs.stat(vanillaShaderDir)
if (stat.isDirectory()) {
const entries = await fs.readdir(vanillaShaderDir)
if (entries.length > 0) {
await fs.rm(vanillaShaderDir, { recursive: true, force: true })
opts.log?.(t('log.baseShaderOverrideStripped', {
path: entries.join(', '),
mc: opts.mcVersion,
format: maxFmt
}))
}
}
} catch {
// 없으면 정상. 무시.
}
}
// pack_format <= 64 인 MC 는 supported_formats 를, > 64 인 MC 는 min_format/max_format 을
// 읽는다. 어느 한쪽만 두면 반대편 클라이언트에서 거부되므로 양쪽 모두 기록한다.
const packMeta: Record<string, unknown> = {
description: t('pack.description', { name: opts.packName }),
pack_format: resolved.format,
supported_formats: { min_inclusive: minFmt, max_inclusive: maxFmt },
min_format: minFmt,
max_format: maxFmt
}
const mcmeta = { pack: packMeta }
await fs.writeFile(path.join(root, 'pack.mcmeta'), JSON.stringify(mcmeta, null, 2) + '\n')
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
// 2) 음악 파일 복사 + sounds.json 생성/병합
const musicFiles = (await fs.readdir(opts.musicDir))
.filter((n) => n.toLowerCase().endsWith('.ogg'))
.sort()
// 베이스의 sounds.json 이 있으면 읽어서 우리 트랙을 덧붙인다.
const soundsJsonPath = path.join(root, 'assets', NAMESPACE, 'sounds.json')
let soundsJson: Record<string, unknown> = {}
try {
const existing = await fs.readFile(soundsJsonPath, 'utf8')
const parsed = JSON.parse(existing)
if (parsed && typeof parsed === 'object') {
soundsJson = parsed as Record<string, unknown>
opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length }))
}
} catch {
// 없으면 새로 생성.
}
for (const fname of musicFiles) {
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
const stem = path.basename(fname, path.extname(fname)) // "01"
const trackId = `track_${stem}`
await fs.copyFile(path.join(opts.musicDir, fname), path.join(soundsDir, `${trackId}.ogg`))
soundsJson[trackId] = {
sounds: [
{ name: `${NAMESPACE}:${trackId}`, stream: true }
]
}
}
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
const paintingFiles = (await fs.readdir(opts.paintingDir))
.filter((n) => n.toLowerCase().endsWith('.png'))
.sort()
for (const fname of paintingFiles) {
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname))
}
// 4) zip 으로 묶기
await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true })
await zipDirectory(root, opts.outZipPath)
// 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요.
}
function zipDirectory(srcDir: string, outZipPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const output = createWriteStream(outZipPath)
const archive = archiver('zip', { zlib: { level: 9 } })
output.on('close', () => resolve())
output.on('error', reject)
archive.on('warning', (err: Error & { code?: string }) => {
// ENOENT 정도면 무시, 그 외는 reject.
if (err.code === 'ENOENT') return
reject(err)
})
archive.on('error', reject)
archive.pipe(output)
archive.directory(srcDir, false)
archive.finalize().catch(reject)
})
}